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 применял, но беру только там, где над модулем работает большая команда и нужны жёсткие контракты: за границы платишь бойлерплейтом. Критерий один — тестируемость и стоимость изменения, а не мода.