Настройка архітектури MVVM для iOS-застосунку
MVVM на iOS — це не один паттерн, а сім'я реалізацій. MVVM з Combine, MVVM з async/await та @Observable, MVVM з RxSwift — технічно різні підходи з одним іменем. Правильна настройка залежить від цільової версії iOS та переваг команди.
MVVM з Combine (iOS 13+)
Класична реалізація з ObservableObject та @Published:
final class ProfileViewModel: ObservableObject {
@Published var user: User?
@Published var isLoading = false
@Published var error: AppError?
private let userRepository: UserRepository
private var cancellables = Set<AnyCancellable>()
init(userRepository: UserRepository) {
self.userRepository = userRepository
}
func loadProfile() {
isLoading = true
userRepository.fetchCurrentUser()
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] completion in
self?.isLoading = false
if case .failure(let error) = completion {
self?.error = error
}
},
receiveValue: { [weak self] user in
self?.user = user
}
)
.store(in: &cancellables)
}
}
Слабкі місця: cancellables потрібно явно зберігати (інакше підписка одразу скасується), утечки пам'яті через [weak self] у замиканнях — типова причина краху при навігації назад. Настраюємо deinit з логуванням для перевірки життєвого циклу в debug.
MVVM з @Observable (iOS 17+)
Макрос @Observable з фреймворку Observation забирає boilerplate:
@Observable
final class ProfileViewModel {
var user: User?
var isLoading = false
var error: AppError?
private let userRepository: UserRepository
init(userRepository: UserRepository) {
self.userRepository = userRepository
}
func loadProfile() async {
isLoading = true
defer { isLoading = false }
do {
user = try await userRepository.fetchCurrentUser()
} catch {
self.error = error as? AppError
}
}
}
SwiftUI автоматично відстежує залежності — re-рендер тільки при зміні використаних властивостей. Ні @Published, ні cancellables. Мінус: тільки iOS 17+, що обмежує застосування до кінця 2025 року для більшості проектів з широкою аудиторією.
Паттерн Coordinator + MVVM
Чистий MVVM не вирішує навігацію. ViewModel не повинна знати про екрани. Coordinator інкапсулює навігаційну логіку:
protocol ProfileCoordinator: AnyObject {
func showEditProfile(user: User)
func showSettings()
}
final class ProfileViewModel {
weak var coordinator: ProfileCoordinator?
// ...
func editProfileTapped() {
guard let user else { return }
coordinator?.showEditProfile(user: user)
}
}
Coordinator створює ViewModel та ін'єктує залежності. ViewModel не імпортує UIKit — тестується в ізоляції без запуску симулятора.
Dependency Injection
Без DI MVVM перетворюється на ProfileViewModel() з UserRepository() прямо всередині — неможливо підмінити на mock в тестах. Настраюємо DI-контейнер:
- Resolver (Swinject-fork) — популярен, легковесен
-
Swift Dependency від PointFree — строгий, з підтримкою контролю залежностей у тестах через
withDependencies - Ручний DI через
Environment— допустимий для невеликих проектів
Тестованість
Це головне преімущество MVVM при правильній настройці:
func testLoadProfile_success() async {
let mockRepository = MockUserRepository(result: .success(User.fixture))
let sut = ProfileViewModel(userRepository: mockRepository)
await sut.loadProfile()
XCTAssertEqual(sut.user?.id, User.fixture.id)
XCTAssertFalse(sut.isLoading)
XCTAssertNil(sut.error)
}
Ніякого XCTestExpectation для async — з async/await тести ViewModel пишуються лінійно.
Що настраюємо
Аналізуємо текущу структуру проекту → вибираємо Combine або @Observable залежно від min deployment target → створюємо базові протоколи ViewModel → настраюємо Coordinator для навігації → конфігуруємо DI → створюємо приклади для команди з unit-тестами. При необхідності — рефакторинг існуючих MVC ViewController на MVVM.
Робота займає 2–4 дня для нового проекту. Міграція legacy MVC → MVVM залежить від обсягу існуючого коду.







