Настройка архитектуры 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 framework убирает бойлерплейт:
@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 автоматически отслеживает зависимости — ре-рендер только при изменении используемых свойств. Нет @Published, нет cancellables. Минус: только iOS 17+, что ограничивает применение до конца 2025 года для большинства проектов с широкой аудиторией.
Координаторный паттерн + MVVM
Чистый MVVM не решает навигацию. ViewModel не должна знать об экранах. Координатор инкапсулирует навигационную логику:
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 зависит от объёма существующего кода.







