Если вы когда-нибудь открывали проект, где ViewModel превратилась в свалку логики, навигации и форматирования дат - поздравляю, вы видели MVVM-курильщика. Рассказываю, почему «чистый» MVVM из учебников рассыпается в бою, как превратить ViewModel в машину состояний вместо мусорного ведра, и почему import UIKit в вашей VM - это диагноз. Без воды, с примерами кода и болью. Эта статья - не очередной пересказ документации. Вы научитесь разделять данные и их представление так, чтобы тесты писались сами собой, а ваши коллеги не проклинали вас на код-ревью.
Я придерживаюсь мнения, что архитектура - это не про то, как разложить файлы по папкам, а про то, как вы управляете сложностью и состоянием. Давайте разберем, как заставить MVVM работать на вас, а не против вас.
Почему MVVM часто проваливается на практике
Большинство проблем с MVVM проистекают из неверного понимания ответственности. Часто ViewModel воспринимается как «место, куда я кладу всё, что не влезло в View».
Основные причины провала:
Нарушение инкапсуляции: View знает слишком много о внутренностях ViewModel, или, что еще хуже, ViewModel хранит ссылки на UI-компоненты. Если в вашей ViewModel есть
import UIKit(или любая другая UI-библиотека), у вас проблемы.Отсутствие четкого State: Переменные
@Published var name,@Published var isLoading,@Published var errorживут своей жизнью. В итоге можно получить состояние, когда и спиннер крутится, и ошибка показывается одновременно. Это «невалидное состояние», и это грех.Логика навигации внутри VM: ViewModel не должна решать, куда идти дальше. Ее дело - сказать: «Я закончила работу, данные сохранены». А вот кто и куда после этого поведет пользователя - задача координатора или роутера.
Основные принципы: разделение, тестируемость, unidirectional data flow
Забудьте про двустороннее связывание (Two-Way Binding) как про стандарт. Оно уместно в простых формах ввода, но в сложных экранах оно превращает поток данных в хаос. Будущее (и настоящее) - за Unidirectional Data Flow (UDF).
View отправляет Action (нажатие кнопки,
viewDidLoad).ViewModel обрабатывает Action, дергает сервис и обновляет State.
View подписывается на State и перерисовывается.
Это делает систему предсказуемой. Вы всегда знаете, какое событие привело к изменению состояния. К тому же, это в разы упрощает тестирование: вы просто подаете на вход экшен и проверяете, соответствует ли итоговый стейт ожидаемому.
ViewModel как машина состояний (а не просто мешок свойств)
Вместо россыпи разрозненных свойств я предпочитаю использовать единый State. Идеальный инструмент для этого - enum.
Swift
final class ProductListViewModel: ObservableObject { enum State { case idle case loading case loaded([Product]) case error(String) } @Published private(set) var state: State = .idle private let repository: ProductRepositoryProtocol init(repository: ProductRepositoryProtocol) { self.repository = repository } func loadProducts() { state = .loading repository.fetchProducts { [weak self] result in switch result { case .success(let products): self?.state = .loaded(products) case .failure(let error): self?.state = .error(error.localizedDescription) } } } }
Почему это круто? Потому что View теперь максимально тупая. Она просто «рендерит» стейт. В SwiftUI это превращается в элегантный switch внутри body. Я категорически против логики в View, даже если это простой if-else. Чем меньше View «думает», тем меньше шансов поймать странные баги при рендеринге.
Обработка побочных эффектов: работа с сетью, аналитика, навигация
ViewModel - это диспетчер. Она не должна сама лезть в сеть или писать в базу. Она вызывает абстракцию (протокол).
Навигация
Я сторонник паттерна Coordinator. ViewModel должна сообщать о необходимости навигации через замыкание или делегат.
Swift
final class LoginViewModel: ObservableObject { var onLoginSuccess: (() -> Void)? func handleLogin() { // ... логика авторизации onLoginSuccess?() } }
Аналитика
Не засоряйте методы бизнес-логики вызовами Analytics.log(...). Это «побочный эффект». Лучше всего выносить это в отдельные декораторы или использовать обсерверы, которые следят за изменением состояния. Но если проект небольшой, я допускаю инъекцию сервиса аналитики во ViewModel, лишь бы это не превращалось в спагетти.
Стратегии биндинга: Combine vs замыкания vs @Published
Выбор инструмента зависит от вашего стека и религии.
@Published (SwiftUI): Самый простой и лаконичный вариант. Но будьте осторожны: обновление происходит в
objectWillChange, что иногда приводит к нюансам в жизненном цикле.Combine: Дает мощь операторов (
debounce,filter,combineLatest). Если у вас сложный ввод с валидацией «на лету», Combine незаменим. Но отладка длинных цепочек - это отдельный вид мазохизма.Closures: Олдскульный и самый быстрый вариант. Никаких внешних зависимостей, никакой магии. Если вы пишете библиотеку, это лучший выбор, чтобы не навязывать пользователю Combine или RxSwift.
Я лично предпочитаю Combine для iOS 13+, но стараюсь держать цепочки короткими. Если цепочка больше 5-6 операторов - пора разбивать ее на части или выносить логику в отдельный метод.
Тестирование ViewModel без UIKit/SwiftUI
Если вы не можете протестировать ViewModel без создания экземпляра UIViewController или View - ваша архитектура провалена. Тест должен выглядеть примерно так:
Swift
func test_onLoad_setsLoadingState() { let mockRepository = MockProductRepository() let sut = ProductListViewModel(repository: mockRepository) sut.loadProducts() if case .loading = sut.state { // success } else { XCTFail("Expected .loading state") } }
Я всегда использую Dependency Injection через инициализатор. Это позволяет легко подсовывать моки. Тестировать асинхронщину в Combine чуть сложнее (нужны Expectations), но это всё равно в разы быстрее, чем гонять UI-тесты.
Антипаттерны, которых стоит избегать
Massive ViewModel: Если ваша VM перевалила за 500 строк - режьте её. Выносите логику форматирования в
Formatter, работу с данными - вService, а сложные трансформации - вUse Case(привет, Clean Architecture).Leaky Views: Передача
UIобъектов во ViewModel. Никогда не передавайтеUIImageилиNSAttributedString. ПередавайтеDataили простоString. ViewModel должна жить в мире чистой логики.Shared ViewModels: Использование одной и той же инстанс-модели для разных экранов через Singleton. Это прямой путь к состоянию «кто-то поменял данные на третьем экране, и у меня всё упало». Каждому экрану - своя ViewModel. Если нужно делиться данными - используйте общий Service или Storage.
MVVM - это не догма, а инструмент. Он прекрасно масштабируется, если не пытаться сделать из него «архитектуру всего приложения». В реальности MVVM отлично дружит с координаторами для навигации и сервисами для бизнес-логики.
Главное - помнить: ViewModel отвечает за что показывать, а View - за как это выглядит. Если вы разделите эти понятия в голове, ваш код станет чище, а сон - спокойнее.
