ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Clean Architecture 3(Presentation Layer)
    IOS/아키텍처 2024. 4. 4. 20:32

    본 글은 https://tech.olx.com/clean-architecture-and-mvvm-on-ios-c9d167d9f5b3 Oleh Kudinov님의 클린 아키택처 코드와 이론들을 분석하고 포스팅한 글입니다. 이어지는 글이니 참고 부탁드립니다.

     

    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

    2024.03.10 - [IOS/아키텍처] - Clean Architecture 1(Layer 알아보기)

     

    Clean Architecture 1(Layer 알아보기)

    어떻게 하면 관심사 분리부터 유지보수에 용이한 구조를 만들 수 있을까 고민이 들어 Clean Architecture에 대해 공부한 걸 정리하려 합니다. Clean Architecture를 보면 기본 컨샙은 다음과 같습니다. Depen

    jjunbbang.tistory.com

    2024.03.14 - [IOS/아키텍처] - Clean Architecture 2(Domain Layer 파헤치기)

     

    Clean Architecture 2(Domain Layer 파헤치기)

    지난 글에 이어서 작성한 글입니다. Clean Architecture의 구성 요소의 정의와 Data Flow가 궁금하시다면 아래 링크를 참고해주세요. 2024.03.10 - [IOS/아키텍처] - Clean Architecture 1(Layer 알아보기) Clean Architect

    jjunbbang.tistory.com

    1.  MVVM과 클린아키택처 의존성 방향

    MVVM Design Pattern은 데이터 흐름이 단 방향인 패턴 중 가장 일반적인 패턴입니다.

    제가 참고하고 있는 클린 아키텍처 글에선 Presentation영역을 MVVM이라 표현하며 영역 View, ViewModel, Model을 각각 하나의 관심이라는 기준을 잡고 관심사 분리를 통해 클린 아키텍처를 구조를 잡고 있습니다.

     

    의존성 방향은 앞선 글에서 설명한 대로 View -> ViewModel -> Model(Domain) 방향입니다.

    목적은, ViewModel이 어떤 UIFramework가 오더라도 (UIKit, SwiftUI) 영향도가 없는 구조를 만들기 위함이며 도메인 영역은 ViewModel의 의존성이 향하지 않는 것이 키포인트입니다.

     

    2. ViewModel 코드

    해당 예제 프로젝트에선 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 ?? ""
        }
    }
    1. MoviesListViewModelInput, MoviesListViewModelOutput
      1. Input의 경우 화면에서 뷰의 동작을 받기 위한 메소드 들이 나열되어 있고 Output의 경우 Input에 따라 방출되는 값을 저장하거나 핸들링하기 위한 Observable 같은 기능을 가진 추상 메서드가 존재합니다. 
      2. 해당 프로토콜을 MoviesListViewModel 프로토콜 이름으로 그룹화했습니다.
      3. 목적은 해당 프로토콜을 coform 하는 ViewModel을 쉽게 Mocking, Test 하기 위함이라 소개되어 있네요.
        1. 생각해 보면 특정 ViewModel을 사용할 때 이렇게 추상화를 통해 테스트 목적에 맞게 사용할 수 있을 거 같습니다. 그래서 testable 하다 할 수 있겠네요.
    2. Actions
      1. MoviesSearchFlowCoordinator에게 다른 뷰를 표출하는 이벤트를 전달하기 위해 사용합니다.
      2. coordinator에 의해 정의되고 특정 뷰를 표출하는 역할을 합니다.
      3. 화면전환은 독립적인 부분이라 인스턴스를 공유하거나 하지 않기 때문에 Struct를 사용한 거 같습니다. 이렇게 Struct로 정의하면서 불변성과 안정성을 확보하게 되는 거 같습니다.
      4. start 메소드 부분에 강한참조로 makeMoviesListViewController를 호출에 viewController에 해당하는 viewmodel에게 액션을 매개변수로 전달합니다.
      5. 예제코드
    protocol MoviesSearchFlowCoordinatorDependencies  {
        func makeMoviesListViewController() -> UIViewController
        func makeMoviesDetailsViewController(movie: Movie) -> UIViewController
    }
    
    final class MoviesSearchFlowCoordinator {
        
        private weak var navigationController: UINavigationController?
        private let dependencies: MoviesSearchFlowCoordinatorDependencies
    
        init(navigationController: UINavigationController,
             dependencies: MoviesSearchFlowCoordinatorDependencies) {
            self.navigationController = navigationController
            self.dependencies = dependencies
        }
        
        func start() {
            // Note: here we keep strong reference with actions closures, 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)
        }
        
        private func showMovieDetails(movie: Movie) {
            let vc = dependencies.makeMoviesDetailsViewController(movie: movie)
            navigationController?.pushViewController(vc, animated: true)
        }
    }

     

    3. Observable

    1. 코드에서 Observable를 확인할 수 있는데 이 부분은 저희가 아는 RxSwift, Combine 프레임워크의 구독, 관찰 기능을 바닐라 swift로 만든 클래스입니다. 해당 글에선 클린아키텍처의 영역들을 어떻게 분리하고 관리하는 것을 포인트로 잡았기 때문에 정확한 코드는 https://tech.olx.com/clean-architecture-and-mvvm-on-ios-c9d167d9f5b3 원본 블로그에서 확인하시면 될 거 같습니다.

    3. 화면 간 동작 흐름

     

    실행 흐름 요약

    • Step 1-1 : AppDelegate 실행시  MoviesSearchFlowCoordinator내부 Action(화면 전환 플로우)를 정의한 것을 Action Struct 인스턴스화
    • Step 1-2 : Action 인스턴스를 MoviewSceneDiContainer를 통해 Actions Usecase ViewModel 주입 후. ViewController 생성  표출
    • Step 2 : ViewController의 이벤트 발생 시 ViewModel의 action을 수행 -> Coordinator 정의한 화면 전환 플로우가 실행

    클래스 다이어그램으로 파악하기

    클린 아키택처의 예제 코드를 보며 클래스 다이어그램으로 도식화한 내용입니다.

    Presentation영역과 Domain영역의 의존성 방향이 어떻게 구현되어 있는지 이해하기 위한 목적으로 작성해서 특정 클래스 및 구조체에 모든 메소드와 프로퍼티를 작성하진 않았습니다. 이 점을 참고해서 읽어주시면 감사하겠습니다.

     

    1. App런치에서 FlowCoordinator 생성시 DIContainer 관계

    ※ 클래스 다이어그램의 중요 메소드 및 명칭을 소스코드에서 색으로 표시해 구분했습니다.

    코드 요약

    더보기

    final class AppFlowCoordinator {

     

        var navigationController: UINavigationController

        private let appDIContainer: AppDIContainer

        

        init(

            navigationController: UINavigationController,

            appDIContainer: AppDIContainer

        ) {

            self.navigationController = navigationController

            self.appDIContainer = appDIContainer

        }

     

        func start() {

            let moviesSceneDIContainer = appDIContainer.makeMoviesSceneDIContainer()

            let flow = moviesSceneDIContainer.makeMoviesSearchFlowCoordinator(navigationController: navigationController)

            flow.start()

        }

    }

     

    final class AppDIContainer {

        

        lazy var appConfiguration = AppConfiguration()

        

        // MARK: - Network

        lazy var apiDataTransferService: DataTransferService = {

            let config = ApiDataNetworkConfig(

                baseURL: URL(string: appConfiguration.apiBaseURL)!,

                queryParameters: [

                    "api_key": appConfiguration.apiKey,

                    "language": NSLocale.preferredLanguages.first ?? "en"

                ]

            )

            

            let apiDataNetwork = DefaultNetworkService(config: config)

            return DefaultDataTransferService(with: apiDataNetwork)

        }()

        lazy var imageDataTransferService: DataTransferService = {

            let config = ApiDataNetworkConfig(

                baseURL: URL(string: appConfiguration.imagesBaseURL)!

            )

            let imagesDataNetwork = DefaultNetworkService(config: config)

            return DefaultDataTransferService(with: imagesDataNetwork)

        }()

        

        // MARK: - DIContainers of scenes

        func makeMoviesSceneDIContainer() -> MoviesSceneDIContainer {

            let dependencies = MoviesSceneDIContainer.Dependencies(

                apiDataTransferService: apiDataTransferService,

                imageDataTransferService: imageDataTransferService

            )

            return MoviesSceneDIContainer(dependencies: dependencies)

        }

    }

    }

     

    class AppDelegate: UIResponder, UIApplicationDelegate {

     let appDIContainer = AppDIContainer()

        var appFlowCoordinator: AppFlowCoordinator?

        var window: UIWindow?

        

        func application(

            _ application: UIApplication,

            didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?

        ) -> Bool {

       appFlowCoordinator = AppFlowCoordinator(

                navigationController: navigationController,

                appDIContainer: appDIContainer

            )

            appFlowCoordinator?.start()

    }

    }

     

    2.  ViewModel에서 MoviesSearchFlowCoordinator에 정의한 화면흐름 

    AppDIContainer에서 APIKey, Repository 인터페이스 의존성을 MoviesSceneDIContainer에 주입하고 있습니다.

    이렇게 앱 내 설정을 Central 한 DIContainer를 두고 비즈니스 로직과 관련된 Usecase, viewmodel, coorinator를 각각 생성해 관리 합니다. 즉, makeMoviesSceneDIContainer를 활용해 것처럼 사용한다면 유지보수가 용이할 거 같네요. 예를 들어 다른 비즈니스 로직과 화면흐름을 추가해야 한다면, 별도의 DIContainer를 생성해 AppDIContainer를 활용한 의존성 주입으로 결합도를 낮출 수 있지 않을까 생각합니다. 다음은 AppDIContainer(Central unit of all injections) MoviesSceneDIContainer를 생성하는 코드입니다.

    //MAKR: AppDIContainer Factory method
    func makeMoviesSceneDIContainer() -> MoviesSceneDIContainer {
        let dependencies = MoviesSceneDIContainer.Dependencies(
            apiDataTransferService: apiDataTransferService,
            imageDataTransferService: imageDataTransferService
        )
        return MoviesSceneDIContainer(dependencies: dependencies)
    }

     

    3. ViewModel과 ViewController 클래스 다이어그램

    View, ViewController, ViewModelView의 관계를 나타낸 클래스 다이어그램입니다. DefaultMoviesListViewModel에 정의된 acitons들이 Coordinator에 정의된 흐름대로 화면들이 전환되는 것을 참고하면 ViewModel이 UseCase에 의존하고 ViewController와 View는 ViewModel에 의존하는 방향을 볼수 있습니다. 즉,  클린 아키텍처의 의도대로 의존성이 Presentation -> Domain  방향인 것을 확인 할 수 있습니다.

     

    'IOS > 아키텍처' 카테고리의 다른 글

    Clean Architecture 4(Data Layer)  (0) 2024.04.05
    Clean Architecture 2(Domain Layer 파헤치기)  (0) 2024.03.14
    Clean Architecture 1(Layer 알아보기)  (0) 2024.03.10
Designed by Tistory.