Настройка архітектури 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 протокол — інтерфейс для 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 дня для нового проекту.







