Если вы когда-нибудь открывали проект, где ViewModel превратилась в свалку логики, навигации и форматирования дат - поздравляю, вы видели MVVM-курильщика. Рассказываю, почему «чистый» MVVM из учебников рассыпается в бою, как превратить ViewModel в машину состояний вместо мусорного ведра, и почему import UIKit в вашей VM - это диагноз. Без воды, с примерами кода и болью. Эта статья - не очередной пересказ документации. Вы научитесь разделять данные и их представление так, чтобы тесты писались сами собой, а ваши коллеги не проклинали вас на код-ревью.

Я придерживаюсь мнения, что архитектура - это не про то, как разложить файлы по папкам, а про то, как вы управляете сложностью и состоянием. Давайте разберем, как заставить MVVM работать на вас, а не против вас.

Почему MVVM часто проваливается на практике

Большинство проблем с MVVM проистекают из неверного понимания ответственности. Часто ViewModel воспринимается как «место, куда я кладу всё, что не влезло в View».

Основные причины провала:

  1. Нарушение инкапсуляции: View знает слишком много о внутренностях ViewModel, или, что еще хуже, ViewModel хранит ссылки на UI-компоненты. Если в вашей ViewModel есть import UIKit (или любая другая UI-библиотека), у вас проблемы.

  2. Отсутствие четкого State: Переменные @Published var name, @Published var isLoading, @Published var error живут своей жизнью. В итоге можно получить состояние, когда и спиннер крутится, и ошибка показывается одновременно. Это «невалидное состояние», и это грех.

  3. Логика навигации внутри 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-тесты.

Антипаттерны, которых стоит избегать

  1. Massive ViewModel: Если ваша VM перевалила за 500 строк - режьте её. Выносите логику форматирования в Formatter, работу с данными - в Service, а сложные трансформации - в Use Case (привет, Clean Architecture).

  2. Leaky Views: Передача UI объектов во ViewModel. Никогда не передавайте UIImage или NSAttributedString. Передавайте Data или просто String. ViewModel должна жить в мире чистой логики.

  3. Shared ViewModels: Использование одной и той же инстанс-модели для разных экранов через Singleton. Это прямой путь к состоянию «кто-то поменял данные на третьем экране, и у меня всё упало». Каждому экрану - своя ViewModel. Если нужно делиться данными - используйте общий Service или Storage.


MVVM - это не догма, а инструмент. Он прекрасно масштабируется, если не пытаться сделать из него «архитектуру всего приложения». В реальности MVVM отлично дружит с координаторами для навигации и сервисами для бизнес-логики.

Главное - помнить: ViewModel отвечает за что показывать, а View - за как это выглядит. Если вы разделите эти понятия в голове, ваш код станет чище, а сон - спокойнее.