+4

Clean Architecture và MVVM trên iOS (Swift) phần 1

Tổng quan

Khi mà chúng ta phát triển phần mềm thì điều quan trọng là không những sử dụng design patterns, mà còn architectural pattern. Có rất nhiều architectural pattern trong kỹ thuật phần mềm. Trong mobile, sử dụng rộng rãi nhất là MVVM, Clean Architecture và Redux patterns.

Trong bài viết lần này, tôi sẽ show trên Working_Example_Project, cách mà architectural pattern MVVM & Clean Architecture ứng dụng vào iOS app.

Vì nội dung khá dài nên bài viết mình sẽ chia làm 2 phần. Ở phần này mình sẽ tập trung nói về Clean Architecture và phần tiếp theo sẽ nói về MVVM.

Nếu bạn cũng muốn tìm hiểu về Redux, hãy xem cuốn sách này: Advanced iOS App Architecture.

Xem thêm thông tin về Clean Architecture

image.png

Như bạn có thể thấy trên mô hình Clean Architecture, chúng ta có các layer khác nhau trên ứng dụng. Nguyên tắc chính là không có sự phụ thuộc từ các layer bên trong đến các layer bên ngoài. Các mũi tên chỉ từ ngoài vào trong là Dependency Rule (nguyên tắc độc lập). Chỉ có thể có các phụ thuộc từ layer ngoài vào trong.

Sau khi group các layer, chúng ta có 3 layer chính: Presentation, Domain and Data layers.

image.png

Domain Layer (Business logic) là layer bên trong cùng của mô hình (không phụ thuộc vào các layer khác, nó hoàn toàn độc lập). chúng bao gồm Entities(Business Models), Use Cases, and Repository Interfaces. Layer này có thể được sử dụng lại trong các dự án khác nhau. Sự phân tách này cho phép không sử dụng ứng dụng chủ trong mục tiêu kiểm thử vì không cần phụ thuộc (bao gồm cả phụ thuộc từ bên thứ ba). Điều này làm cho Domain Use Cases test chỉ mất vài giây. Lưu ý: Domain layer không được bao gồm bất kỳ thứ gì từ các layer khác (ví dụ: Presentation — UIKit or SwiftUI or Data Layer — Mapping Codable) .

Lý do mà good architecture tập trung vào UseCases là để các developer có thể mô tả một cách an toàn các cấu trúc hỗ trợ các UseCases đó mà không import Framework, tools và environment. Nó được gọi là Screaming Architecture.

Presentation Layer bao gồm UI (UIViewControllers or SwiftUI Views), Views được điều phối bởi ViewModels (Presenters) thực hiện một hay nhiều Usecase. Presentation Layer chỉ phụ thuộc vào Domain Layer.

Data Layer chứa các Responsitory Implementations và một hoặc nhiều Datasource. Responsitory chịu trách nhiệm điều phối data từ nhiều nguồn data khác nhau. Datasource có thể là remote hoặc local.Data Layer chỉ phụ thuộc vào Domain Layer. Trong layer này, chúng ta cũng có thể add thêm mapping của Network JSON Data (ví dụ: Decodable performance) tới Domain model.

Trên biểu đồ bên dưới, mọi thành phần từ mỗi layer được biểu diễn dọc theo Dependency Direction (hướng phụ thuộc) và Data Flow (Request/Response). Chúng ta có thể thấy Dependency Inversion nơi chúng ta sử dụng Repository interfaces(protocols). Phần giải thích của từng Layer sẽ dựa trên Project ví dụ được đề cập ở đầu bài viết.

image.png

Data Flow

  1. View(UI) calls method from ViewModel (Presenter).

  2. ViewModel thực thi Use Case.

  3. Use Case kết hợp data từ User và Repositories.

  4. Mỗi Repository returns data từ Remote Data (Network), Persistent DB Storage Source or In-memory Data (Remote or Cached).

  5. Information flows trở lại View(UI), nơi hiển thị list item.

Dependency Direction

  • Presentation Layer -> Domain Layer <- Data Repositories Layer

  • Presentation Layer (MVVM) = ViewModels(Presenters) + Views(UI)

  • Domain Layer = Entities + Use Cases + Repositories Interfaces

  • Data Repositories Layer = Repositories Implementations + API(Network) + Persistence DB

Example Project: "Movies App"

image.png

Domain Layer

Bên trong Example Project bạn có thể tìm thấy Domain layer. nó chứa Entities, SearchMoviesUseCase tìm kiếm phim và lưu trữ các query thành công gần đây. Ngoài ra, nó chứa Data Repositories Interfaces, nó cần thiết cho Dependency Inversion.

protocol SearchMoviesUseCase {
    func execute(requestValue: SearchMoviesUseCaseRequestValue,
                 completion: @escaping (Result<MoviesPage, Error>) -> Void) -> Cancellable?
}

final class DefaultSearchMoviesUseCase: SearchMoviesUseCase {

    private let moviesRepository: MoviesRepository
    private let moviesQueriesRepository: MoviesQueriesRepository
    
    init(moviesRepository: MoviesRepository, moviesQueriesRepository: MoviesQueriesRepository) {
        self.moviesRepository = moviesRepository
        self.moviesQueriesRepository = moviesQueriesRepository
    }
    
    func execute(requestValue: SearchMoviesUseCaseRequestValue,
                 completion: @escaping (Result<MoviesPage, Error>) -> Void) -> Cancellable? {
        return moviesRepository.fetchMoviesList(query: requestValue.query, page: requestValue.page) { result in
            
            if case .success = result {
                self.moviesQueriesRepository.saveRecentQuery(query: requestValue.query) { _ in }
            }

            completion(result)
        }
    }
}

// Repository Interfaces
protocol MoviesRepository {
    func fetchMoviesList(query: MovieQuery, page: Int, completion: @escaping (Result<MoviesPage, Error>) -> Void) -> Cancellable?
}

protocol MoviesQueriesRepository {
    func fetchRecentsQueries(maxCount: Int, completion: @escaping (Result<[MovieQuery], Error>) -> Void)
    func saveRecentQuery(query: MovieQuery, completion: @escaping (Result<MovieQuery, Error>) -> Void)
}

Note: Cách khác để tạo Use Cases là sử dụng UseCase protocol với start() function và tất cả UseCasse implementations sẽ conform với protocol này. Một trong những UseCase trong example project tiếp cận theo cách này: FetchRecentMovieQueriesUseCase. Use Cases còn được gọi là Interactors

Note: Một UseCase có thể phụ thuộc vào các UseCase khác.

Presentation Layer

Presentation Layer chứa MoviesListViewModel với các item được quan sát từ MoviesListView. MoviesListViewModel không được import UIKit. Bởi vì việc giữ cho ViewModel clean khỏi bất kỳ UI frameworks nào như UIKit, SwiftUI hoặc WatchKit sẽ cho phép tái cấu trúc và tái sử dụng dễ dàng. Ví dụ: trong tương lai, việc tái cấu trúc Views từ UIKit sang SwiftUI sẽ dễ dàng hơn nhiều vì ViewModel sẽ không cần phải thay đổi.

// 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 ?? ""
    }
}

Chúng ta sử dụng interfaces MoviesListViewModelInput và MoviesListViewModelOutput để MoviesListViewController có thể test được bằng cách mocking ViewModel một cách dễ dàng (example).. Ngoài ra, chúng ta có MoviesListViewModelActions closures, nó nói với MoviesSearchFlowCoordinator lúc nào prensent tới các view khác. Khi action closure được gọi, coordinator sẽ present màn hình movie detail. Chúng ta sử dụng một struct để nhóm các action vì sau này chúng ta có thể dễ dàng thêm nhiều action hơn nếu cần.

Presentation layer cũng chứa MoviesListViewController, nó ràng buộc với data (items) của MoviesListViewModel.

UI không thể truy cập vào business logic hoặc application logic (Business Models và UseCases), chỉ ViewModel mới có thể làm được điều đó. nó được gọi là separation of concerns.. Chúng ta không thể chuyển trực tiếp business logic sang View (UI). Đó là lý do tại sao chúng ta mapping Business model bên trong của ViewModel và đưa chúng đến View.

Chúng ta cũng có thể call một sự kiện search từ View đến ViewModel để bắt đầu search movies.

import UIKit

final class MoviesListViewController: UIViewController, StoryboardInstantiable, UISearchBarDelegate {
    
    private var viewModel: MoviesListViewModel!
    
    final class func create(with viewModel: MoviesListViewModel) -> MoviesListViewController {
        let vc = MoviesListViewController.instantiateViewController()
        vc.viewModel = viewModel
        return vc
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        bind(to: viewModel)
    }
    
    private func bind(to viewModel: MoviesListViewModel) {
        viewModel.items.observe(on: self) { [weak self] items in
            self?.moviesTableViewController?.items = items
        }
        viewModel.error.observe(on: self) { [weak self] error in
            self?.showError(error)
        }
    }
    
    func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
        guard let searchText = searchBar.text, !searchText.isEmpty else { return }
        viewModel.didSearch(query: searchText)
    }
}

Chúng ta quan sát các item và reload khi mà chúng có sự thay đổi. Chúng tôi sử dụng observerble, nó được giải thích trong phần nội dung MVVM bên dưới đây:

Chúng ta chỉ định function showMovieDetails(movie:) cho action của MoviesListViewModel bên trong MovieSearchFlowCoordinator để present lên Movie Details Screen từ Coordinator flow:

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)
    }
}

Note: Chúng tả sử dụng Flow Coordinator cho presentation logic để giảm tải kích thước và trách nhiệm của viewcontroller. Chúng ta có strong reference đến Flow (action closures, self functions) để giữ cho Flow không bị hủy trong khi cần thiết.

Với cách tiếp cận này, chúng ta có thể dễ dàng sử dụng các view khác nhau với cùng ViewModel mà không cần sửa chúng. Chúng ta chỉ có thể kiểm tra if iOS 13.0 is available và sau đó tạo một SwiftUI View thay vì UIKit và liên kết nó với cùng một ViewModel, nếu không chúng ta tạo UIKit View. Trong example_project chúng ta tạo SwiftUI example cho MoviesQueriesSuggestionsList. Cần ít nhất Xcode 11 Beta.

// MARK: - Movies Queries Suggestions List
func makeMoviesQueriesSuggestionsListViewController(didSelect: @escaping MoviesQueryListViewModelDidSelectAction) -> UIViewController {
   if #available(iOS 13.0, *) { // SwiftUI
       let view = MoviesQueryListView(viewModelWrapper: makeMoviesQueryListViewModelWrapper(didSelect: didSelect))
       return UIHostingController(rootView: view)
   } else { // UIKit
       return MoviesQueriesTableViewController.create(with: makeMoviesQueryListViewModel(didSelect: didSelect))
   }
}

Data Layer

Data Layer bao gồm DefaultMoviesRepository, nó phù hợp với các interface định nghĩa bên trong Domain Layer (Dependency Inversion). Chúng tôi cũng add vào đó mapping của JSON data (Decodable Confirmance)CoreData Entities cho Domain Model.

final class DefaultMoviesRepository {
    
    private let dataTransferService: DataTransfer
    
    init(dataTransferService: DataTransfer) {
        self.dataTransferService = dataTransferService
    }
}

extension DefaultMoviesRepository: MoviesRepository {
    
    public func fetchMoviesList(query: MovieQuery, page: Int, completion: @escaping (Result<MoviesPage, Error>) -> Void) -> Cancellable? {
        
        let endpoint = APIEndpoints.getMovies(with: MoviesRequestDTO(query: query.query,
                                                                     page: page))
        return dataTransferService.request(with: endpoint) { (response: Result<MoviesResponseDTO, Error>) in
            switch response {
            case .success(let moviesResponseDTO):
                completion(.success(moviesResponseDTO.toDomain()))
            case .failure(let error):
                completion(.failure(error))
            }
        }
    }
}

// MARK: - Data Transfer Object (DTO)
// It is used as intermediate object to encode/decode JSON response into domain, inside DataTransferService
struct MoviesRequestDTO: Encodable {
    let query: String
    let page: Int
}

struct MoviesResponseDTO: Decodable {
    private enum CodingKeys: String, CodingKey {
        case page
        case totalPages = "total_pages"
        case movies = "results"
    }
    let page: Int
    let totalPages: Int
    let movies: [MovieDTO]
}
...
// MARK: - Mappings to Domain
extension MoviesResponseDTO {
    func toDomain() -> MoviesPage {
        return .init(page: page,
                     totalPages: totalPages,
                     movies: movies.map { $0.toDomain() })
    }
}
...

Note: Đối với Data Transfer Object (DTO) được sử dụng làm đối tượng trung gian để ánh xạ từ response của JSON vào Domain. Ngoài ra nếu chúng ta muốn cache EndPoint response, chúng ta có thể lưu Data Transfer Objects trong bộ lưu trữ liên tục bằng cách mapping chúng thành các đối tượng liên tục (ví dụ: DTO -> NSManagedObject).

Nói chung Data Repositories có thể được truyền vào với API Data ServicePersistent Data Storage. Data Repository làm việc với 2 dependencies để trả về data. Quy tắc trước tiên là yêu cầu lưu trữ liên tục cho đầu ra dữ liệu được lưu trong bộ nhớ cache (NSManagedObject được map vào Domain bằng DTO object, và được truy xuất trong cached data closure). Sau đó, để call API Data Service nó sẽ trả về data mới nhất. Sau khi Persistent Storage được updated data mới nhất (DTOs được map thành Persistent Objects lưu lại). Và sau đó DTO được map thành Domain và truy xuất trong updated data/completion closure. Đó là cách user sẽ thấy data ngay lập trức. Ngay cả khi không có kết nối internet, người dùng vẫn sẽ thấy dữ liệu mới nhất từ Persistent Storage. example

Bộ lưu trữ và API có thể được thay thế bằng các triển khai hoàn toàn khác (ví dụ thay đổi từ CoreData thành Realm). Mặc dù tất cả các lớp còn lại của ứng dụng sẽ không bị ảnh hưởng bởi thay đổi này, nhưng điều này là do DB là một chi tiết.

Infrastructure Layer (Network)

Nó bao bọc xung quanh Network Framework, có thể là Alamofire (hoặc framework khác), Nó có thể được cấu hình với các tham số khác (ví dụ như BaseURL). Nó cũng hỗ trợ định nghĩa các EndPoint và chứa các phương thức map data. (sử dụng Decodable)

struct APIEndpoints {
    
    static func getMovies(with moviesRequestDTO: MoviesRequestDTO) -> Endpoint<MoviesResponseDTO> {

        return Endpoint(path: "search/movie/",
                        method: .get,
                        queryParametersEncodable: moviesRequestDTO)
    }
}


let config = ApiDataNetworkConfig(baseURL: URL(string: appConfigurations.apiBaseURL)!,
                                  queryParameters: ["api_key": appConfigurations.apiKey])
let apiDataNetwork = DefaultNetworkService(session: URLSession.shared,
                                           config: config)

let endpoint = APIEndpoints.getMovies(with: MoviesRequestDTO(query: query.query,
                                                             page: page))
dataTransferService.request(with: endpoint) { (response: Result<MoviesResponseDTO, Error>) in
    let moviesPage = try? response.get()
}

Note: Bạn có thể đọc nhiều hơn ở đây.

Hết Phần 1

link gốc của bài viết (tác giả Oleh Kudinov) : https://tech.olx.com/clean-architecture-and-mvvm-on-ios-c9d167d9f5b3 Link liên quan: https://github.com/kudoleh/iOS-Clean-Architecture-MVVM


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí