Налаштування архітектури MVP для iOS-додатку
Уявіть: кодова база UIKit-додатку розрослася до 20+ екранів, кожен ViewController тягне 500+ рядків, а тести відсутні або вимагають запуску симулятора. Ви пробували MVVM, але зв'язка Combine з @Published не вписалася — проєкт занадто старий, або команда прийшла з Android з її MVP-культурою. Наша команда має досвід налаштування MVP на 15+ проєктах і гарантує працездатність шаблону з першого коміту. У цій статті розберемо ключові протоколи, ізоляцію UIKit і тестування без симулятора.
Wikipedia: Model-View-Presenter — це архітектурний патерн, в якому Presenter виступає посередником між View та Model. На iOS це особливо корисно для великих проєктів, де MVVM з Combine вимагає переписування коду під реактивний стек.
Чому MVP підходить для UIKit-проєктів?
Якщо ваш проєкт — не простий список з деталями, а складний багатоекранний UIKit, MVP дає чітке розділення: Presenter не бачить UIKit, ViewController тонший, тести пишуться без симулятора. На відміну від MVVM, де ViewModel підписується на Combine, MVP використовує прості протоколи та слабкі посилання. Це знижує поріг входу і не вимагає переписування коду під реактивний стек. Такий підхід є елементом чистої архітектури iOS, оскільки залежності направлені від зовнішніх шарів до ядра.
Що таке MVP на iOS і в чому відмінність від MVVM?
За визначенням Model-View-Presenter — це архітектурний патерн, в якому Presenter виступає посередником між View та Model. У MVVM ViewController підписується на @Published-властивості ViewModel через Combine. У MVP Presenter не знає про UIKit: він спілкується з протоколом View, який реалізує ViewController. Немає біндінгів, немає реактивності — повна контрольованість потоку даних.
// 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!
override func viewDidLoad() {
super.viewDidLoad()
presenter.viewDidLoad()
}
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 тестується без симулятора. Ось приклад з моком:
final class MockProfileView: ProfileView {
var didShowUser = false
var isLoading = false
func showUser(_ user: User) { didShowUser = true }
func showLoading(_ isOn: Bool) { isLoading = isOn }
func showError(_ message: String) { /* ignore */ }
}
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)
}
Тест займає мілісекунди, не вимагає XCUITest. Покриття коду такими тестами досягає 80%, а економія коштів на регресі — до 40% бюджету. Порівняйте: типова MVVM-ViewModel з Combine вимагає налаштування XCTestExpectation або Sink — MVP тут значно простіший і швидший.
Що входить у налаштування MVP?
При замовленні налаштування MVP ви отримуєте:
- Повні Swift-файли модуля: Presenter, ViewController, Router.
- Unit-тести Presenter (5–10 кейсів на модуль, покриття 80%).
- Інтеграційні інструкції для додавання нових екранів.
- Навчання команди (1–2 дзвінки з демонстрацією).
Робота займає 2–3 дні для нового проєкту. Зв'яжіться з нами для оцінки обсягу міграції існуючих екранів. Отримайте консультацію щодо впровадження MVP у ваш проєкт.
Навігація в 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:) — без знання того, що відбувається під капотом.
Як мігрувати MVC на MVP?
Міграція існуючого MVC-екрану на MVP — задача на 4–8 годин на екран. Створіть протокол View, виділіть логіку в Presenter, додайте Router. Перенесіть залежність від Model в Presenter. Реалізуйте View у ViewController. Тестуйте Presenter. Такий покроковий план застосовний для будь-якого екрану, а підсумкова архітектура стає модульною і тестованою.
Покрокове налаштування MVP для нового проєкту
- Визначте модулі. Кожен екран — окремий модуль з протоколами View, Presenter, Router.
- Напишіть протоколи. View — тільки методи відображення; Presenter — методи життєвого циклу та обробки подій; Router — навігаційні методи.
- Реалізуйте Presenter. Вся логіка, робота з сервером/БД. Жодного UIKit.
- Реалізуйте View (ViewController). Прокидайте виклики до Presenter.
- Реалізуйте Router. Створіть клас, який використовує UINavigationController для переходів.
- Налаштуйте DI. Використовуйте фабрику модулів або Swinject для компонування залежностей.
- Напишіть тести. Покрийте кожен метод Presenter (5–10 кейсів на модуль).
| Крок |
Тривалість |
| Визначення модулів |
1–2 год |
| Протоколи та Presenter |
3–4 год |
| View та Router |
2–3 год |
| DI та фабрика |
1–2 год |
| Тести (10 кейсів) |
2–3 год |
| Інтеграція |
1 год |
Порівняння MVP і MVVM
| Характеристика |
MVP |
MVVM |
| Залежність від UIKit |
Ні (Presenter) |
Ні (ViewModel) |
| Біндінг |
Відсутній |
Combine (або Rx) |
| Тестування без симулятора |
Так |
Так (із застереженнями) |
| Складність на SwiftUI |
Не застосовний |
Природний |
| Стек у проєкті |
UIKit |
UIKit / SwiftUI |
Які типові помилки допускають при впровадженні MVP?
- Забути weak на view — retain cycle, витік пам'яті. Завжди використовуйте
weak var view: ViewProtocol?.
- Змішувати логіку навігації в Presenter — виділіть Router.
- Не тестувати Presenter — втрачаєте головну перевагу MVP. Покривайте моками.
- Переускладнювати протоколи — один метод на екран замість дроблення. Тримайте кількість методів View у розумних межах (5–7).
Замовте налаштування MVP для вашого проєкту — отримайте готовий шаблон за 2–3 дні. Ми гарантуємо, що ваш проєкт отримає структуру, зручну для тестування, а команда — робочий процес без зайвого оверхеду. Зв'яжіться з нами для консультації.
MVVM, Clean Architecture, BLoC, VIPER, TCA: проєктуємо архітектуру під ключ
Додаток зібрано в одному ViewController на 2000 рядків. Мережеві виклики, бізнес-логіка, оновлення UI — все в одному місці. Додати нову фічу без регресії складно, написати тест неможливо. Це не «поганий код» — це відсутність архітектури. І це трапляється частіше, ніж можна очікувати, навіть у production-додатках з мільйоном користувачів.
Ми проєктуємо архітектуру під ключ: від вибору паттерну до повної структури проєкту з тестами та документацією. За 7–10 днів отримуєте чистий модульний код, готовий до масштабування. Ваша команда зможе додавати нові фічі без ризику зламати існуючі, а тести покриють ключову логіку вже на старті. Архітектурні паттерни в мобайлі вирішують одне завдання: відокремити UI від логіки так, щоб кожна частина була тестованою та замінною.
MVVM — базовий паттерн
Model-View-ViewModel — стандарт для iOS (SwiftUI + Combine/async, UIKit + Combine) та Android (Jetpack ViewModel + StateFlow + Compose). ViewModel містить стан UI та бізнес-логіку. View лише відображає стан і передає наміри користувача до ViewModel. Model — дані та їх джерело.
Ключове правило: ViewModel не знає про UIKit або Android View-класи. Немає імпортів UIKit, немає Context-залежностей (крім Application context через Hilt). Це гарантує тестованість: ViewModel тестується як чистий Kotlin/Swift-код без Android Instrumented Test.
MVVM закриває 70% потреб. Інші 30% — де потрібна строга ізоляція фіч, масштабування команди, складний flow управління станом.
Clean Architecture — коли MVVM недостатньо
Додає шари поверх MVVM:
Domain-шар — бізнес-логіка, незалежна від платформи. UseCase (або Interactor) містить одне бізнес-правило: GetUserOrdersUseCase, PlaceOrderUseCase. Залежить лише від інтерфейсів (protocol/interface), не від конкретних реалізацій.
Data-шар — реалізація репозиторіїв. OrderRepositoryImpl реалізує OrderRepository з domain. Знає про Retrofit, Room, UserDefaults. ViewModel не знає, звідки дані — з мережі чи кешу.
Presentation-шар — ViewModel + View. Знає про Domain, не знає про Data.
Dependency rule: залежності спрямовані лише всередину. Domain не залежить ні від чого. Data та Presentation залежать від Domain.
Presentation → Domain ← Data
Це дає можливість підміняти реалізацію: тест використовує in-memory репозиторій замість мережевого, інтерфейс залишається тим самим.
Практичне зауваження: Clean Architecture додає файли та шари. Для невеликого додатку це overhead. Виправдано від ~15 фіч та при команді 3+ розробників.
BLoC для Flutter — передбачуваний потік станів
BLoC (Business Logic Component) — стандартний паттерн у Flutter-спільноті. Бібліотека flutter_bloc реалізує його через два типи: Bloc (Event → State) та Cubit (State без Events, лише методи).
Bloc обробляє Event та емітує новий State через on<EventType> хендлери. Стан імутабельний — новий об'єкт на кожну зміну. BlocBuilder перемальовує лише ту частину дерева, де змінився state.
// Подія
alias CartEvent {}
class AddItemToCart extends CartEvent {
final String productId;
AddItemToCart(this.productId);
}
// Стан
alias CartState {}
class CartLoaded extends CartState {
final List<CartItem> items;
CartLoaded(this.items);
}
// Bloc
class CartBloc extends Bloc<CartEvent, CartState> {
CartBloc(this._cartRepository) : super(CartLoaded([])) {
on<AddItemToCart>(_onAddItem);
}
Future<void> _onAddItem(AddItemToCart event, Emitter<CartState> emit) async {
final current = state as CartLoaded;
final updated = await _cartRepository.addItem(event.productId);
emit(CartLoaded(updated));
}
}
Перевага BLoC — тестованість. blocTest з bloc_test пакету дозволяє перевірити: при такому-то Event, з таким-то початковим State, BLoC має емітувати такий-то State. Без UI, без моків для Flutter-фреймворку.
VIPER — для великих iOS-проєктів
VIPER (View, Interactor, Presenter, Entity, Router) — найбільш строгий поділ обов'язків для iOS. Кожен компонент має протокол та конкретну реалізацію.
- View — лише UI, делегує все Presenter
- Interactor — бізнес-логіка, робота з мережею та даними
- Presenter — посередник між View та Interactor, форматує дані для View
- Entity — моделі даних (чисті структури)
- Router — навігація між модулями
Кожен модуль (екран або фіча) — окремий VIPER-модуль. Це виключає coupling між фічами та дозволяє великим командам працювати паралельно без конфліктів.
Ціна: багато файлів, багато протоколів. Шаблонний код генерується через Sourcery або кастомні Xcode-шаблони. VIPER виправданий для додатків з 10+ розробниками та 50+ екранами.
TCA (The Composable Architecture)
TCA від Point-Free — більш сучасна альтернатива VIPER для iOS/macOS. Основні концепції: State (імутабельний стан фічі), Action (всі можливі події), Reducer (State + Action → новий State + Effect), Store (зберігає State, обробляє Actions).
Scope дозволяє composable будувати великі фічі з маленьких: батьківський Reducer делегує частину State дочірньому. Кожна фіча тестується ізольовано через TestStore з точним контролем над Effects.
TCA має круту криву навчання, але дає передбачуваність, яку складно отримати іншим способом: кожна зміна стану — явний Action з конкретним джерелом.
Як обрати архітектуру мобільних додатків?
Оцінимо проєкт за 1 день — підберемо архітектуру з урахуванням розміру команди, платформи та планів зростання.
| Паттерн |
Платформа |
Команда |
Коли обирати |
| MVVM |
iOS, Android, Flutter |
1–5 |
Стартовий стандарт, MVP, невеликі проєкти |
| MVVM + Clean |
iOS, Android |
3–10 |
Середні проєкти, тестованість критична |
| BLoC |
Flutter |
2–8 |
Flutter з передбачуваним state management |
| VIPER |
iOS |
5–20 |
Великі iOS-проєкти, модульна архітектура |
| TCA |
iOS/macOS |
3–15 |
Сувора тестованість, Swift Concurrency |
Універсальної відповіді немає. Архітектуру обирають під розмір команди, вимоги до тестованості та горизонт підтримки додатку.
Що входить у нашу роботу
Процес складається з кількох кроків:
-
Аудит поточної архітектури (якщо додаток вже існує) — виявимо вузькі місця та регресійні зони.
-
Проєктування модульної структури з чіткими межами шарів та правилами залежностей.
-
Створення каркасу проєкту (Scaffold) з впровадженням DI, організації папок та налаштування лінтерів.
-
Написання юніт-тестів для шару домену та ViewModel — мінімум 80% покриття ключових use case.
-
Підготовка документації — архітектурні діаграми, README з правилами модифікації коду, інструкція для онбордингу нових розробників.
-
Передача робочого репозиторію з CI-пайплайном (GitHub Actions / Bitrise), налаштованим запуском тестів та статичним аналізом.
Все це входить у вартість проєктування. Додатково — підтримка на етапі впровадження: консультації команди, code review перших pull request.
Що відбувається без архітектури
Типовий сценарій через 18 місяців без архітектури: 40% часу розробки йде на дебаг регресій. Новий розробник розбирається в коді тиждень перед тим, як зробити перший PR. Тести не пишуться, «тому що складно мокувати». Додавання нової фічі вимагає розуміння половини кодової бази.
Вибір архітектури на старті — це інвестиція з поверненням через 3–6 місяців. За нашими даними, правильно спроєктована архітектура з MVVM + Clean дає в 3 рази менше регресій порівняно з монолітним ViewController. А витрати на її впровадження окупаються за 2–3 спринти. Середня економія на усуненні дефектів після впровадження — до 40% часу, що для команди з п'яти осіб може означати понад 10 000 доларів на рік.
Згідно з рекомендаціями Apple щодо проєктування додатків, розділення обов'язків — ключовий фактор стійкості коду.
Чому варто довірити архітектуру професіоналам?
Неправильний вибір паттерну на старті веде до переписування половини коду через рік. Ми бачили десятки проєктів, де спроба заощадити на архітектурі оберталася багатомісячним рефакторингом. У нас за плечима 10+ років комерційної розробки, досвід роботи з додатками від 1 до 50 розробників, понад 100 успішно реалізованих проєктів. Ми допомагаємо уникнути типових помилок:
- Overengineering для простого MVP (призначаємо MVVM, а не VIPER).
- Відсутність dependency injection — підключаємо Hilt/Koin/Dagger вже на старті.
- Ігнорування тестованості — закладаємо протоколи/інтерфейси з першого коміту.
Зв'яжіться з нами через Telegram або email — отримайте безкоштовну оцінку вашого проєкту та рекомендацію щодо оптимальної архітектури. Замовте проєктування вже сьогодні — і за тиждень стартуйте з чистим, масштабованим кодом.
Додаткова таблиця: порівняння витрат на впровадження
| Паттерн |
Час проєктування |
Кількість файлів на 1 екран |
Час написання тестів |
| MVVM |
2–3 дні |
5–7 |
1 день |
| MVVM + Clean |
4–5 днів |
10–12 |
2 дні |
| BLoC |
3–4 дні |
6–8 |
1.5 дня |
| VIPER |
5–7 днів |
12–15 |
2.5 дня |
| TCA |
5–6 днів |
8–10 |
2 дні |
Час вказано для команди з 2–3 розробників. З нашим шаблоном (generator) стартовий каркас готовий за 1 день незалежно від обраного паттерну.