Настройка архитектуры MVP для iOS-приложения
MVP на iOS вышел из моды с приходом SwiftUI и MVVM+Combine, но на UIKit-проектах с большой кодовой базой он по-прежнему оправдан. Особенно там, где команда пришла из Android (где MVP был стандартом до Jetpack) или где исторически сложилась MVP-структура, которую незачем ломать ради тренда.
Чем MVP отличается от MVVM на UIKit
В MVVM ViewController подписывается на @Published-свойства ViewModel через Combine. В MVP — Presenter не знает о UIKit: он работает с протоколом View, а ViewController реализует этот протокол и сам обновляет UI. Нет биндингов, нет реактивности — зато полная контролируемость потока данных.
// View protocol — интерфейс для Presenter
protocol ProfileView: AnyObject {
func showUser(_ user: User)
func showLoading(_ isLoading: Bool)
func showError(_ message: String)
}
// Presenter — чистый Swift, ноль UIKit
final class ProfilePresenter {
weak var view: ProfileView?
private let userRepository: UserRepository
init(userRepository: UserRepository) {
self.userRepository = userRepository
}
func viewDidLoad() {
view?.showLoading(true)
Task {
do {
let user = try await userRepository.fetchCurrentUser()
await MainActor.run {
view?.showLoading(false)
view?.showUser(user)
}
} catch {
await MainActor.run {
view?.showLoading(false)
view?.showError(error.localizedDescription)
}
}
}
}
}
// ViewController — тонкий, только UI
final class ProfileViewController: UIViewController, ProfileView {
private var presenter: ProfilePresenter!
func showUser(_ user: User) {
nameLabel.text = user.name
avatarImageView.load(url: user.avatarURL)
}
func showLoading(_ isLoading: Bool) {
isLoading ? activityIndicator.startAnimating() : activityIndicator.stopAnimating()
}
func showError(_ message: String) {
// Toast или Alert
}
}
Ключевой момент: weak var view: ProfileView? — слабая ссылка обязательна, иначе retain cycle. Presenter держит View, View держит Presenter — один из них должен быть weak.
Тестирование Presenter
Главное преимущество MVP — Presenter тестируется без симулятора:
func testViewDidLoad_success() async {
let mockView = MockProfileView()
let mockRepository = MockUserRepository(result: .success(User.fixture))
let sut = ProfilePresenter(userRepository: mockRepository)
sut.view = mockView
sut.viewDidLoad()
// Небольшая пауза для async Task
try await Task.sleep(nanoseconds: 100_000_000)
XCTAssertTrue(mockView.didShowUser)
XCTAssertFalse(mockView.isLoading)
}
MockProfileView реализует ProfileView с флагами вызовов. Тест занимает миллисекунды, не требует XCUITest.
Навигация в MVP: Router / Wireframe
Presenter не должен управлять навигацией напрямую — это нарушает Single Responsibility. Классическое решение: Router (или Wireframe в оригинальном MVP терминах):
protocol ProfileRouter: AnyObject {
func navigateToEditProfile(user: User)
func navigateToSettings()
}
Конкретный ProfileRouterImpl работает с UINavigationController — UIKit-зависимость изолирована. Presenter получает Router через DI и вызывает router.navigateToEditProfile(user:) — без знания о том, что происходит под капотом.
Когда MVP лучше MVVM
MVP удобен, когда команда активно пишет unit-тесты для логики экранов и не хочет добавлять Combine как зависимость. Также — при большом количестве UIKit-экранов с UITableView / UICollectionView: Presenter удобно обрабатывает события делегатов, View просто пробрасывает вызовы.
На SwiftUI-проектах MVP неорганичен — там нет UIViewController, паттерн не ложится естественно.
Что настраиваем
Проектируем базовые протоколы View, Presenter, Router → создаём фабрику модулей (каждый экран — ProfileModule.build() возвращает UIViewController) → настраиваем DI → создаём пример модуля с тестами → при необходимости мигрируем существующие MVC-экраны.
Работа занимает 2–3 дня для нового проекта.







