Исходный код

При разработке ПО важно использовать не только дизайн-, но и архитектурные паттерны. Их существует довольно много. В мобильной разработке самые распространенные - MVVM, Clean Architecture и Redux.

В этой статье мы покажем на примерах проектов как паттерны MVVM и Clean Architecture могут быть применены в iOS приложении.

Если вы также интересуетесь Redux, зацените эту книгу.

Как мы можем видеть на схеме Clean Architecture, у нас есть различные слои приложения. Главное правило - не делать зависимостей внутренних слоев от внешних. Стрелки, указывающие снаружи внтурь это Dependency Rule. Зависимости могут идти только от внешних слоев внутрь к центру.

После группировки у нас получились следующие слои:

Presentation, Domain и Data

Domain слой (бизнес-логика) - самый внутренний слой нашей “луковицы” (без зависимостей, он полностью изолирован). Он содержит в себе Entities (сущности/бизнес модели), Use Cases и Repository Interfaces. Этот слой потенциально может быть переиспользован в других проектах. Такое разделение позволяет не использовать host-приложение в тестовых целях, так как не нужны никакие зависимости (в том числе сторонние). Поэтому Domain Use Cases тесты выполняются за несколько секунд. Важно: Domain слой не должен включать в себя что-либо из других слоев.
  • Хорошая архитектура выстроена вокруг Use Cases для того, чтобы разработчики могли безопасно описывать структуры, которые поддерживают Use Cases, не применяя фреймворки и другие тулзы. Это называется Screaming Architecture.

  • Presentation слой содержит UI (UIViewController, SwiftUI View). Вьюхи координирются вью-моделями (или презентерами), которые выполняют один или несколько Use Cases. Presentation слой зависит только от Domain слоя.

  • Data слой содержит имплементации репозитория и один/несколько Data Source. Репозитории ответственны за координацию данных из разных дата-сорсов. Дата-сорсы могут быть удаленные или локальные (например, persistent database). Data слой зависит только от Domain слоя. В этом слое мы также можем добавить маппинг Network JSON Data в модели Domain.

На схеме ниже каждый компонент каждого слоя показан с направлением зависимости и Data Flow (Request/Response). Мы можем видеть инверсию зависимостей (Dependency Inversion), которая указывает, где мы используем интерфейс репозитория(протоколы). Объясним каждый слой на примере проект, который упоминали в начале статьи.

Data Flow

1. View(UI) вызывает метод из ViewModel (Presenter).

2. ViewModel выполняет Use Case.

3. Use Case комбинирует данные из User и Repositories.

4. Каждый Repository возвращает данные Remote Data (Network), Persistent DBStorage Source или In-memory Data (удаленную или кэшированную).

5. Информация приходит назад в View(UI), где отображается в списке элементов.

Направление зависимостей

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

Пример - проект “Movies App”

Domain слой

Внутри проекта мы находим Domain Layer. Он содержит Entities, SearchMoviesUseCase, которые ищут фильм и сохраняют последние успешные запросы. Также слой содержит Data Repositories Interfaces, которые нужны для инверсии зависимостей.

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&lt;MoviesPage, Error&gt;) -&gt; Void) -&gt; 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)
}

Важно: Еще один способ создать Use Cases это использовать UseCase протокол с функцией start() и подписать на него все имплементации Use Cases. Один из кейсов в нашем примере так и делает: FetchRecentMovieQueriesUseCase. Use Cases также называют Interactors

Важно:  UseCase может зависеть от других UseCases

Presentation слой

Этот слой содержит MoviesListViewModel с айтемами, которые надбюдаются из MoviesListView. MoviesListViewModel не импортирует UIKit. Потому что не добавляя во ViewModel такие фрейворки как UIKit, SwiftUI или WatchKit, мы сможем ее лучше переиспользовать и тестировать. В будущем, например, рефакторить Views без UIKit или SwiftUI будет гораздо проще, так как не придется менять ViewModel.

// Важно: Не имортируем UIKit или SwiftUI
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 {
// Важно: если понадобится изменить фильм внутри Details экрана и обновить  
// MoviesList экран новым фильмом, используйте этот клоужер:
//  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&lt;[MoviesListItemViewModel]&gt; = Observable([])
let error: Observable&lt;String&gt; = 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):
            // Важно: Здесь мы обязаны замапить из  Domain Entities в Item View Models. Разделение Domain и 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-методы
extension MoviesListViewModel {
func didSearch(query: String) {
    load(movieQuery: MovieQuery(query: query))
}

func didSelect(at indexPath: IndexPath) {
    actions?.showMovieDetails(movies[indexPath.row])
}

}
// Важно: Эта вьюмодель - для показа данных, и не содержит какую-либо domain модель, чтобы к ней не обращались view
struct MoviesListItemViewModel: Equatable {
let title: String
}
extension MoviesListItemViewModel {
init(movie: Movie) {
self.title = movie.title ?? ""
}
}

Важно: Мы используем интерфейсы MoviesListViewModelInput и MoviesListViewModelOutput, чтобы сделатьMoviesListViewController тестируемым ( сделав мок ViewModel) . Также у нас есть клоужеры MoviesListViewModelActions, которые сообщают MoviesSearchFlowCoordinator когда показывать другие View. Когда вызовутся эти клоужеры, координатор покажет экран подробностей о фильме. Мы используем структуру для группировки функций, чтобы позднее можно было добавить новые.

Presentation слой также содержит MoviesListViewController который связан с датой(items) из MoviesListViewModel.

У UI нет доступа к бизнес-логике или логике приложения (Business Models и UseCases), он есть только у ViewModel. Это разделение ответственности. Мы не можем прокинуть бизнес модель напрямую во View (UI). Поэтому мы маппим Business Models к ViewModel внутри ViewModel и прокидываем их в View.

Также добавим поисковой запрос из View во ViewModel, чтобы начать искать фильмы:

import UIKit
final class MoviesListViewController: UIViewController, StoryboardInstantiable, UISearchBarDelegate {
private var viewModel: MoviesListViewModel!

final class func create(with viewModel: MoviesListViewModel) -&gt; 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)
}

}

Важно: Мы наблюдаем за айтемами иперезагружаем View, когда они изменяются. Мы используем здесь Observable, который объясняется в обзоре MVVM ниже.

Также назначаем функцию showMovieDetails(movie:) в Actions нашейMoviesListViewModel внутри MoviesSearchFlowCoordinator, чтобы презентовать экран подробностей из flow coordinator:

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() {
    // Важно: Тут мы сохраняем сильную ссылку через клоужер, таким образов к данному флоу не надо обращаться по сильно ссылке
    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)
}

}

Важно: Мы используем Flow Coordinator для логики презентации, сокращая объем View Controllers и снижая их ответственность. У нас strong ссылка на Flow (с клоужерами, self функциями), чтобы Flow не деаллоцировался, пока он нужен.

С этим подходом мы легко используем разные View с одной ViewModel, не меняя ее. Просто проверяем совместимость с iOS 13.0 и потом создаем SwiftUI View вместо UIKit и биндим ее к той же ViewModel (или создаем UIKit View). В этом проекте мы добавили SwiftUI пример для MoviesQueriesSuggestionsList*. Нужен хотя бы 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 слой

Этот слой содержит DefaultMoviesRepository. Он подписан на interfaces, определенные внутри Domain Layer (Dependency Inversion). Мы также добавляем маппинг JSON data(Decodable conformance) и CoreData Entities в Domain Models.

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&lt;MoviesPage, Error&gt;) -&gt; Void) -&gt; Cancellable? {

    let endpoint = APIEndpoints.getMovies(with: MoviesRequestDTO(query: query.query,
                                                                 page: page))
    return dataTransferService.request(with: endpoint) { (response: Result&lt;MoviesResponseDTO, Error&gt;) in
        switch response {
        case .success(let moviesResponseDTO):
            completion(.success(moviesResponseDTO.toDomain()))
        case .failure(let error):
            completion(.failure(error))
        }
    }
}

}
// MARK: - Data Transfer Object (DTO)
// Используется как промежуточный объект для encode/decode JSON response в домен, внутри 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: - Мапинг в Domain
extension MoviesResponseDTO {
func toDomain() -> MoviesPage {
return .init(page: page,
totalPages: totalPages,
movies: movies.map { $0.toDomain() })
}
}
...

Важно: Data Transfer Objects DTO используются как посредник для маппинга из JSON response в Domain. Также, если мы хотим кэшировать endpoint response, мы будем хранить Data Transfer Objects в persistent storage, замапив их в Persistent objects(DTO -> NSManagedObject).

В целом Data Repositories могут быть внедрены с помощью API Data Service и Persistent Data Storage. Data Repository работает с этими двумя зависимостями и возвращает данные. Надо сначала попросить разрешения у persistent storage для аутпута кэшированных данных (NSManagedObject замаплены в Domain с помощью DTO object, и достаются в  cached data closure). Потом вызываем API Data Service, который возвращается последние обновления данных. Затем Persistent Storage обновляется этими данными (DTO замаплены в Persistent Objects и сохранены). После этого DTO мапятся в Domain и достаются в updated data/completion closure. Таким образом данные сразу будут показаны пользователю. Даже если нет соединения с интернетом, пользователи все равно увидят последние данные из Persistent Storage. example

Хранилище и API могут быть заменены совершенно разными имплементациями (от CoreData до Realm, например). Все остальные слои приложения не будут затронуты этими изменениями, потому что Storage это просто деталь механизма.

Infrastructure слой (Network)

Это обертка над сетевым фреймворком, она может быть Alamofire (или другой фреймворк). Ее можно сконфигурировать сетевыми параметрами (например, базовым URL). Она также поддерживает endpoints и содержит методы мапинга данных (используя Decodable).

struct APIEndpoints {
static func getMovies(with moviesRequestDTO: MoviesRequestDTO) -&gt; Endpoint&lt;MoviesResponseDTO&gt; {

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

Подробнее по ссылке: https://github.com/kudoleh/SENetworking

MVVM

Model-View-ViewModel паттерн (MVVM) позволяет разделить ответственность между UI и Domain.

Вместе с Clean Architecture он может помочь разделить ответственность между Presentation и UI слоями.

Разные имплементации view могут быть использованы с одной ViewModel. Например, можно использовать CarsAroundListView и CarsAroundMapView и использовать CarsAroundViewModel для обоих. Вы также можете имплементировать одно View из UIKit, а другое View из SwiftUI. Важно помнить, что не надо импортировать UIKit, WatchKit или SwiftUI внутри вашей ViewModel. Таким образом ее легко можно будет переиспользовать.

Data Binding между View и ViewModel может быть выполнен с помощью closures, delegates или observables (например, RxSwift). Combine и SwiftUI также можно использовать, но только если минимальная поддерживаемая версия это iOS 13. У View есть прямое отношение к ViewModel, оно ей сообщает от каждом событии, произошедшем во View. У ViewModel нет прямого сообщения с View (только Data Binding).

В этом примере мы используем простую комбинацию Closure и didSet, чтобы избежать сторонних зависимостей:

public final class Observable<Value> {
private var closure: ((Value) -&gt; ())?

public var value: Value {
    didSet { closure?(value) }
}

public init(_ value: Value) {
    self.value = value
}

public func observe(_ closure: @escaping (Value) -&gt; Void) {
    self.closure = closure
    closure(value)
}

}

Важно: Это очень упрощенная версия Observable, чтобы посмотреть на полную имплементацию с несколькими и observer removal: Observable.

Пример data binding из ViewController:

final class ExampleViewController: UIViewController {
private var viewModel: MoviesListViewModel!

private func bind(to viewModel: ViewModel) {
    self.viewModel = viewModel
    viewModel.items.observe(on: self) { [weak self] items in
        self?.tableViewController?.items = items
        // Важно: Нельзя использовать viewModel внутри клоужера, это создаст retain cycle memory leak (viewModel.items.value - нельзя)
        // self?.tableViewController?.items = viewModel.items.value // Это будет retain cycle. Доступ к viewModel только через self?.viewModel
    }
    // или в одну строчку
    viewModel.items.observe(on: self) { [weak self] in self?.tableViewController?.items = $0 }
}

override func viewDidLoad() {
    super.viewDidLoad()
    bind(to: viewModel)
    viewModel.viewDidLoad()
}

}
protocol ViewModelInput {
func viewDidLoad()
}
protocol ViewModelOutput {
var items: Observable<[ItemViewModel]> { get }
}
protocol ViewModel: ViewModelInput, ViewModelOutput {}

Важно: Доступ к viewModel из observing closure закрыт, он вызывает retain cycle(утечка памяти). Доступ к viewModel только через: self?.viewModel.

Пример data binding в TableViewCell (Reusable Cell):

final class MoviesListItemCell: UITableViewCell {
private var viewModel: MoviesListItemViewModel! { didSet { unbind(from: oldValue) } }

func fill(with viewModel: MoviesListItemViewModel) {
    self.viewModel = viewModel
    bind(to: viewModel)
}

private func bind(to viewModel: MoviesListItemViewModel) {
    viewModel.posterImage.observe(on: self) { [weak self] in self?.imageView.image = $0.flatMap(UIImage.init) }
}

private func unbind(from item: MoviesListItemViewModel?) {
    item?.posterImage.remove(observer: self)
}

}

Важно: Надо разбиндить, если view переисползуется (например, UITableViewCell)

MVVM шаблоны можно найти здесь:  here

MVVMs Communication

Делегирование

ViewModel одного экрана MVVM коммуницирует с другой ViewModel другого экрана MVVM через паттерн делегирования:

Например, у нас есть ItemsListViewModel и ItemEditViewModel. Потом создаем протокол ItemEditViewModelDelegate с методом ItemEditViewModelDidEditItem(item). И подписываем его на этот протокол:

extension ListItemsViewModel: ItemEditViewModelDelegate

// Step 1: Определите делегат и добавьте в первую ViewModel как weak property
protocol MoviesQueryListViewModelDelegate: class {
func moviesQueriesListDidSelect(movieQuery: MovieQuery)
}
...
final class DefaultMoviesQueryListViewModel: MoviesListViewModel {
private weak var delegate: MoviesQueryListViewModelDelegate?
func didSelect(item: MoviesQueryListViewItemModel) {
    // Note: Замапим View Item Model к Domain Enity
    delegate?.moviesQueriesListDidSelect(movieQuery: MovieQuery(query: item.query))
}

}
// Step 2:  Подпишем вторую ViewModel на этот делегат
extension MoviesListViewModel: MoviesQueryListViewModelDelegate {
func moviesQueriesListDidSelect(movieQuery: MovieQuery) {
update(movieQuery: movieQuery)
}
}

Важно: В данном случае Delegates можно назвать Responders: ItemEditViewModelResponder

Closures

Другой способ коммуникации это использование closures, которые инъецируются с помощью FlowCoordinator. В примере проекта мы видим, как MoviesListViewModel использует closure showMovieQueriesSuggestions для показа MoviesQueriesSuggestionsView.Также он прокидывает параметр (**_didSelect: MovieQuery) -> Void так, чтобы его можно было вызывать во View. Коммуникация происходит в  MoviesSearchFlowCoordinator:

// MoviesQueryList.swift
// Step 1: Определите кложуер для общения с ViewModel
typealias MoviesQueryListViewModelDidSelectAction = (MovieQuery) -> Void
// Step 2: Вызовите клоужер когда понадобится
class MoviesQueryListViewModel {
init(didSelect: MoviesQueryListViewModelDidSelectAction? = nil) {
self.didSelect = didSelect
}
func didSelect(item: MoviesQueryListItemViewModel) {
didSelect?(MovieQuery(query: item.query))
}
}
// MoviesQueryList.swift
// Step 3: Во время презентации MoviesQueryListView нам надо передать клоужер как параметр (_ didSelect: MovieQuery) -> Void
struct MoviesListViewModelActions {
let showMovieQueriesSuggestions: @escapingg (_ didSelect: MovieQuery) -> Void) -> Void
}
class MoviesListViewModel {
var actions: MoviesListViewModelActions?
func showQueriesSuggestions() {
    actions?.showMovieQueriesSuggestions { self.update(movieQuery: $0) }
    //or simpler actions?.showMovieQueriesSuggestions(update)
}

}
// FlowCoordinator.swift
// Step 4: Внутри FlowCoordinator мы соединяем две viewModels, инъецируя клоужер как self функцию
class MoviesSearchFlowCoordinator {
func start() {
let actions = MoviesListViewModelActions(showMovieQueriesSuggestions: self.showMovieQueriesSuggestions)
let vc = dependencies.makeMoviesListViewController(actions: actions)
present(vc)
}
private func showMovieQueriesSuggestions(didSelect: @escaping (MovieQuery) -&gt; Void) {
    let vc = dependencies.makeMoviesQueriesSuggestionsListViewController(didSelect: didSelect)
    present(vc)
}

}

Разделение слоев на фреймворки (модули)

Теперь каждый слой (Domain, Presentation, UI, Data, Infrastructure Network) нашего приложения можно легко разделить на фреймворки.

New Project -> Create Project… -> Cocoa Touch Framework

Далее можно включить эти фреймворки в наше основное приложение, используя CocoaPods. Вот рабочий пример: working example here.

Важно: Вам надо удалить ExampleMVVM.xcworkspace  и запустить pod install, чтобы сгенерить новый из-за проблем с доступом.

Dependency Injection Container

Dependency injection это техника, при которой один объект предоставляет зависимости другого объекта.  DIContainer в вашем приложении это центральный юнит всех зависимостей.

Используем dependencies factory protocols

Один из вариантов это описать dependencies protocol, который делегирует создание зависимости DIContainer. Чтобы это сделать, надо определить протокол MoviesSearchFlowCoordinatorDependencies  и подписать MoviesSceneDIContainer под этот протокол, затем инъецировать его в MoviesSearchFlowCoordinator, которому нужна это инъекция для создания и показа MoviesListViewController. Вот нужные шаги:

// Определите Dependencies protocol для нужного класса или структуры
protocol MoviesSearchFlowCoordinatorDependencies  {
func makeMoviesListViewController() -> MoviesListViewController
}
class MoviesSearchFlowCoordinator {
private let dependencies: MoviesSearchFlowCoordinatorDependencies

init(dependencies: MoviesSearchFlowCoordinatorDependencies) {
    self.dependencies = dependencies
}

...
}
// Подпишите DIContainer на этот протокол
extension MoviesSceneDIContainer: MoviesSearchFlowCoordinatorDependencies {}
// Инъецируйте MoviesSceneDIContainer self в нужный класс
final class MoviesSceneDIContainer {
...
// MARK: - Flow Coordinators
func makeMoviesSearchFlowCoordinator(navigationController: UINavigationController) -> MoviesSearchFlowCoordinator {
return MoviesSearchFlowCoordinator(navigationController: navigationController,
dependencies: self)
}
}

Используя closures

Другая опция это клоужеры. Определите клоужер внутри нужного класса и потом в него этот клоужер инъецируйте. Например:

// Определите makeMoviesListViewController клоужер, который возвращает MoviesListViewController
class MoviesSearchFlowCoordinator {
private var makeMoviesListViewController: () -&gt; MoviesListViewController

init(navigationController: UINavigationController,
     makeMoviesListViewController: @escaping () -&gt; MoviesListViewController) {
    ...
    self.makeMoviesListViewController = makeMoviesListViewController
}
...

}
// Инъецируйте у MoviesSceneDIContainer self.makeMoviesListViewController фунцкию в нужный класс
final class MoviesSceneDIContainer {
...
// MARK: - Flow Coordinators
func makeMoviesSearchFlowCoordinator(navigationController: UINavigationController) -> MoviesSearchFlowCoordinator {
return MoviesSearchFlowCoordinator(navigationController: navigationController,
makeMoviesListViewController: self.makeMoviesListViewController)
}
// MARK: - Movies List
func makeMoviesListViewController() -&gt; MoviesListViewController {
    ...
}

}

Заключение

Самые используемые архитектуры в мобильной разработке - Clean Architecture(слоями), MVVM, и Redux.

MVVM и Clean Architecture можно, конечно, использовать раздельно, но MVVM предоставляет разделение ответственности только внутри Presentation слоя, тогда как Clean Architecture разделяет код на модульные слои, которые можно легко тестировать, переиспользовать и понимать.

Важно не пропускать создание Use Case, даже если Use Case ничего не делает, кроме вызова Repository. Так ваша архитектура будет понятна для нового разработчика, который увидит Use cases.

Хоть это и хорошая отправная точка, это не панацея. Вы выбираете архитектуру конкретно под свои нужды.

Clean Architecture хорошо работает с TDD (Test Driven Development). Она делает проект пригодным для тестирования и замены слоев (UI and Data).

Domain-Driven Design (DDD) тоже хорошо работает с Clean Architecture(CA).

Еще из software engineering best practices:

  • Не пишите код без тестов (попробуйте TDD)

  • Делайте продолжительный рефакторинг

  • Будьте прагматичными и не переусердствуйте

  • Старайтесь избегать при любой возможности внедрение зависимостей от сторонних фреймворков

Комментарии (4)


  1. Bardakan
    19.04.2024 09:57

    На схеме ниже каждый компонент каждого слоя показан с направлением зависимости и Data Flow (Request/Response). Мы можем видеть инверсию зависимостей (Dependency Inversion), которая указывает, где мы используем интерфейс репозитория(протоколы). Объясним каждый слой на примере проект, который упоминали в начале статьи.

    у вас же на схеме одни сущности, а дальше в описании - другие


  1. kovserg
    19.04.2024 09:57

    Так для чистой архитектуры есть SwiftUI, зачем вы рассказываете про каменный век?

    https://forums.developer.apple.com/forums/thread/699003


    1. hello_my_name_is_dany
      19.04.2024 09:57

      MV, MVVM, MVC и прочее относятся только к Presentation слою в чистой архитектуре. Можете взять любую схему, которая на ваш взгляд удобнее, но сам по себе MV не является clean architecture


  1. nronnie
    19.04.2024 09:57

    Вообще говоря, "чистая архитектура" не является архитектурным паттерном (таким, как, например, CQRS), или же архитектурным стилем (таким, как "слоенная", "гексагональная", или "микросервисная"). Она больше относится к тому что в книге Марка Ричардса и Нила Форда "Fundamentals of Software Architecture" определяется как "архитектурные решения", т.е. набор правил которым должна следовать реализация системы. "Архитектурный стиль" при этом может быть по сути каким угодно. Сам Р. Мартин в своей книге нигде её как какую-то разновидность ("стиль") архитектуры и не определяет.