Если вы когда-нибудь открывали проект, где 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 - за как это выглядит. Если вы разделите эти понятия в голове, ваш код станет чище, а сон - спокойнее.

Комментарии (5)


  1. bighorik
    31.01.2026 09:03

    Спасибо за статью, как раз вынужден оперативно разбираться с авалонией и мввм, очень доступно разложили. Было бы здорово, если пример о навигации был бы более развернутый

    Поставил бы лайк, да карма не позволяет :(


    1. SHK83 Автор
      31.01.2026 09:03

      Будет серия статей про mvvm. И про навигацию тоже будет подробная статья. Постараюсь за следующую неделю все опубликовать. Следите. Спасибо за отзыв.


    1. SHK83 Автор
      31.01.2026 09:03

      Карму немного исправил :)


  1. Bardakan
    31.01.2026 09:03

    enum State {        
        case idle        
        case loading        
        case loaded([Product])        
        case error(String)    
      }

    Вместо россыпи разрозненных свойств я предпочитаю использовать единый State. Идеальный инструмент для этого - enum.

    вы видимо не сталкивались с более сложными view model, где нужно данные из одного State case перегонять в другой.

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

    не совсем понял. Вы предлагаете запретить передавать UI в модель или чтобы модель не использовала swiftui и uikit в целом?
    А еще что вы предлагаете делать, когда у вас NSAttributedString состоит из нескольких частей с разным форматированием, которые нужно еще собрать в одну строку перед тем, как показать?


    1. SHK83 Автор
      31.01.2026 09:03

      Навскидку не придумал для чего мне могло бы понадобиться передавать данные из одного State case в другой. Но поразмыслю над этим, спасибо.

      А про запрет UI во ViewModel - да, если у вас есть необходимость импортить UIKit во viewModel, то это порочная практика. Именно об этом и пишу. Этого всегда можно избежать. В случае с атрибутированной строкой можно написать отдельный helper, который помогал бы решить эту проблему и дергать его из ViewModel. Кмк это бы решило проблему чистого ViewModel.