MVC, MVVM, VIPER и координаторы

Архитектура на собеседовании — не спор о моде, а разговор о том, как вы держите проект изменяемым.

Архитектура — набор договорённостей о том, кто за что отвечает и кто кого знает. Хорошая делает изменения дешёвыми, плохая — дорогими.

Вопрос: «Какую архитектуру вы используете и почему?»

Что на самом деле проверяют

Интервьюер хочет услышать не название, а критерий выбора: какую боль решает подход и чем за это платит. Ответ «MVVM, потому что так принято» работает против вас.

Apple MVC и почему его зовут Massive View Controller

В каноническом MVC контроллер — тонкий посредник. В UIKit UIViewController владеет view, её жизненным циклом, вёрсткой, делегатами таблицы, навигацией — то есть он и View, и Controller одновременно. Логику положить некуда, и она оседает там же: сеть, парсинг, форматирование дат, бизнес-правила.

final class ProfileViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
            guard let data, let user = try? JSONDecoder().decode(User.self, from: data) else { return }
            DispatchQueue.main.async {
                self?.nameLabel.text = user.firstName + " " + user.lastName
                self?.ageLabel.text = "\(user.age) лет"   // склонение тоже здесь
            }
        }.resume()
    }
}

Проблема не в числе строк, а в том, что логику нельзя запустить без экрана: чтобы проверить склонение «лет/года/год», придётся поднимать UIViewController в тесте.

MVVM: вынести всё, что не про пиксели

MVVM разрезает контроллер: ViewModel хранит состояние и правила, View подписывается на изменения. Практический маркер один — ViewModel не импортирует UIKit. Если импортирует, внутрь протекли UIView или навигация.

@MainActor
final class ProfileViewModel: ObservableObject {
    @Published private(set) var title = ""
    @Published private(set) var isLoading = false
    private let service: any UserService

    init(service: any UserService) { self.service = service }

    func load() async {
        isLoading = true
        defer { isLoading = false }
        guard let user = try? await service.fetchUser() else { return }
        title = "\(user.firstName) \(user.lastName)"
    }
}

Биндинг — дело стека: в SwiftUI это @Published + @StateObject, в UIKit — Combine, замыкания var onChange: ((State) -> Void)? или ручной render(state:). Плюс: ViewModel тестируется обычным XCTest без симулятора. Минус: MVVM ничего не говорит про навигацию и про сценарии из нескольких экранов.

VIPER и Clean Architecture

VIPER раскладывает модуль на пять ролей: View, Interactor (бизнес-логика), Presenter (подготовка данных для View), Entity, Router (переходы). Границы жёсткие, все связи — через протоколы. Это окупается, когда модуль пишут одни, а правят другие: контракт виден, конфликтов в git меньше. Цена — 5–7 файлов бойлерплейта на каждый экран, включая «О приложении» с одной надписью.

protocol ArticlesInteractorInput: AnyObject {
    func loadArticles(page: Int)
}
protocol ArticlesInteractorOutput: AnyObject {
    func didLoad(_ articles: [Article])
    func didFail(_ error: Error)
}
protocol ArticlesRouterInput: AnyObject {
    func showDetails(for id: Article.ID)
}

Clean Architecture — обобщение той же идеи: слои Domain (сущности и use case), Data (репозитории, сеть, база), Presentation (UI) и правило зависимостей — внутренние слои не знают о внешних. MVVM и VIPER живут внутри как способ организации Presentation.

Вопрос: «Кто должен решать, какой экран открыть следующим?»

Что на самом деле проверяют

Понимаете ли вы, что навигация — тоже зависимость. Контроллер, который сам делает pushViewController(DetailsViewController(id: id)), знает о чужом экране и его инициализаторе: такой экран не переиспользуешь, и любой тест тянет половину графа экранов.

Coordinator

Координатор — обычный объект, владеющий навигацией и сборкой экранов. Контроллер только сообщает о событии («выбрали статью 42»), решение принимает координатор. Координаторы образуют дерево: AppCoordinator запускает AuthCoordinator или MainCoordinator, те — свои потоки.

protocol Coordinator: AnyObject {
    var childCoordinators: [Coordinator] { get set }
    func start()
}

extension Coordinator {
    func addChild(_ child: Coordinator) { childCoordinators.append(child) }
    func removeChild(_ child: Coordinator) { childCoordinators.removeAll { $0 === child } }
}

final class AppCoordinator: Coordinator {
    var childCoordinators: [Coordinator] = []
    private let window: UIWindow
    private let container: AppContainer

    init(window: UIWindow, container: AppContainer) {
        self.window = window
        self.container = container
    }

    func start() {
        let nav = UINavigationController()
        window.rootViewController = nav
        window.makeKeyAndVisible()
        container.session.isAuthorized ? runMain(on: nav) : runAuth(on: nav)
    }

    private func runAuth(on nav: UINavigationController) {
        let child = AuthCoordinator(navigation: nav, container: container)
        child.onFinish = { [weak self, weak child] in
            guard let self, let child else { return }
            self.removeChild(child)          // иначе поток останется в памяти
            self.runMain(on: nav)
        }
        addChild(child)
        child.start()
    }

    private func runMain(on nav: UINavigationController) { /* ... */ }
}

Deeplink — самый убедительный аргумент за координаторы: ссылка myapp://article/42 разбирается в одном месте и превращается в mainCoordinator.showArticle(42), а не в цепочку presentedViewController?.children.first as? ….

Типичные ошибки кандидатов

  • Называть архитектуру как флаг («у нас VIPER») и не уметь объяснить, что она дала команде и сколько стоила.
  • Считать, что MVVM сам по себе спасает от больших контроллеров: ViewModel с UIKit и навигацией — тот же Massive VC под другим именем.
  • Смешивать роли: Presenter, который сам ходит в сеть, или Interactor, знающий про IndexPath.
  • Забывать removeChild — закрытый поток и весь его граф остаются в памяти; классическая утечка навигации.
  • Держать сильные ссылки в обе стороны дерева: ребёнок на родителя — только weak или через onFinish.

Как ответить кратко

Основа — MVVM: ViewModel без UIKit, поэтому логика и форматирование покрыты юнит-тестами, а View только рисует состояние. Навигацию из контроллеров выношу в координаторы: экран сообщает о событии, координатор решает, что открыть, — так экраны переиспользуются и deeplink разбирается в одном месте. VIPER применял, но беру только там, где над модулем работает большая команда и нужны жёсткие контракты: за границы платишь бойлерплейтом. Критерий один — тестируемость и стоимость изменения, а не мода.

Проверьте себя
1. Какой признак вернее всего показывает, что MVVM в проекте настоящий, а не «Massive VC под другим именем»?
AViewModel лежит в отдельной папке проекта
BViewModel не импортирует UIKit и не владеет навигацией
CКаждый экран состоит ровно из двух файлов
DViewModel помечена как final class
2. Почему ViewController, который сам вызывает pushViewController с конкретным следующим экраном, считают проблемой?
AТакой вызов работает медленнее, чем через координатор
BUIKit запрещает push из viewDidLoad
CЭкран знает о чужих экранах и их инициализаторах, поэтому его нельзя переиспользовать в другом сценарии
DАнимация перехода ломается на iPad
3. Какая утечка чаще всего встречается в реализации координаторов?
AДочерний координатор не удалён из childCoordinators после завершения потока
BUINavigationController удерживает свои контроллеры сильно
CКоординатор хранит ссылку на UIWindow
DЗамыкание onFinish объявлено как var