Site icon AppTractor

SwiftUI в 2025: забудьте MVVM

На дворе 2025 год, а мне всё ещё задают один и тот же вопрос: «Где ваши ViewModel?» Каждый раз, когда я делюсь своим мнением или кодом своих открытых проектов, таких как клиент BlueSky IcySky или даже приложение Medium для iOS, разработчики удивляются, видя чистые, простые представления без единой ViewModel.

Позвольте мне прояснить: вам не нужны ViewModel в SwiftUI.

Никогда не были нужны.

И никогда не будут нужны.

Ловушка MVVM

Когда SwiftUI был запущен в 2019 году, многие разработчики принесли с собой багаж UIKit. Мы настолько привыкли к проблеме Massive View Controller (MVC), что сразу же обратились к MVVM как к спасителю. Но дело в том, что SwiftUI — это не UIKit. Он был разработан с нуля с другой философией.

И эта философия подчёркивается в различных видеороликах WWDC от Apple:

Всё это очень хорошие доклады, и в них почти не упоминается ViewModel.

Почему? Потому что это просто почти чуждо представлениям SwiftUI, построенным на потоке данных.

На самом деле, это даже не первая моя статья на эту тему, у меня уже была другая спорная статья.

Представления SwiftUI — это структуры, а не классы. Они разработаны так, чтобы быть лёгкими, одноразовыми и часто пересоздаваться. Добавляя ViewModel, вы нарушаете этот фундаментальный принцип проектирования.

Представления как выражения чистого состояния

В моём последнем приложении IcySky каждое представление следует тому же шаблону, которому я следую уже много лет. Позвольте мне продемонстрировать это на реальном примере:

struct FeedView: View {
    @Environment(BlueSkyClient.self) private var client
    @Environment(AppTheme.self) private var theme
    
    enum ViewState {
        case loading
        case error(String)
        case loaded([Post])
    }
    
    @State private var viewState: ViewState = .loading
    @State private var isRefreshing = false
    
    var body: some View {
        NavigationStack {
            List {
                switch viewState {
                case .loading:
                    ProgressView("Loading feed...")
                        .frame(maxWidth: .infinity)
                        .listRowSeparator(.hidden)
                        
                case .error(let message):
                    ErrorStateView(
                        message: message,
                        retryAction: { await loadFeed() }
                    )
                    .listRowSeparator(.hidden)
                    
                case .loaded(let posts):
                    ForEach(posts) { post in
                        PostRowView(post: post)
                            .listRowInsets(.init())
                    }
                }
            }
            .listStyle(.plain)
            .refreshable { await refreshFeed() }
            .task { await loadFeed() }
        }
    }
}

Обратите внимание, что здесь происходит. Состояние представления определяется прямо внутри представления с помощью перечисления. Внешняя ViewModel не требуется. View — это всего лишь репрезентация своего состояния — ни больше, ни меньше.

Магия Environment

Вот где SwiftUI блистает. Вместо того, чтобы вручную внедрять зависимости через ViewModel, я использую Environment:

@Environment(BlueSkyClient.self) private var client

private func loadFeed() async {
    do {
        let posts = try await client.getFeed()
        viewState = .loaded(posts)
    } catch {
        viewState = .error(error.localizedDescription)
    }
}

private func refreshFeed() async {
    defer { isRefreshing = false }
    isRefreshing = true
    await loadFeed()
}

Мой BlueSkyClient внедряется через Environment, тестируется независимо и обрабатывает всю сложность сетевых взаимодействий. Представление лишь организует поток пользовательского интерфейса.

Сложность реального мира

«Но, Томас», — можете сказать вы, — «это работает только для простых приложений!»

Боже мой, я постоянно это слышу.

Неправда. IcySky обрабатывает аутентификацию, сложные алгоритмы фидов, взаимодействие с пользователем, навигацию и различные уровни сложности без моделей представлений. Конечно, он ещё не завершён, но проект начинает приобретать внушительные размеры. Ice Cubes был скачан сотни тысяч раз, и, хотя он всё ещё работает на моей «старой» архитектуре, я постепенно отхожу от ViewModel. В нём больше представлений без них, чем с ними.

Миллионы людей используют приложение Medium для iOS, в основном сейчас написанного на SwiftUI, с очень небольшим количеством моделей представлений (это устаревшие SwiftUI, которые мы использовали в 2019 году, до того, как моя компания была приобретена и присоединилась к Medium). При разработке новой функции мы внедряем наши сервисы в среду SwiftUI и создаём на их основе облегчённые представления. Эти представления содержат различные локальные состояния, связанные с взаимодействием с пользователем.

Не стесняйтесь злоупотреблять модификаторами .tasks(:id) и .onChange() — они великолепны и действуют как небольшой редуктор состояния, вызывая побочные эффекты при обновлении значения.

Например, вот headerView из IcySky:

@Environment(BSkyClient.self) var client
@Environment(CurrentUser.self) var currentUser
  
private var headerView: some View {
    FeedsListTitleView(
      filter: $filter,
      searchText: $searchText,
      isInSearch: $isInSearch,
      isSearchFocused: $isSearchFocused
    )
    .task(id: searchText) {
      guard !searchText.isEmpty else { return }
      await searchFeed(query: searchText)
    }
    .onChange(of: isInSearch, initial: false) {
      guard !isInSearch else { return }
      Task { await fetchSuggestedFeed() }
    }
    .onChange(of: currentUser.savedFeeds.count) {
      switch filter {
      case .suggested:
        feeds = feeds.filter { feed in
          !currentUser.savedFeeds.contains { $0.value == feed.uri }
        }
      case .myFeeds:
        Task { await fetchMyFeeds() }
      }
    }
    .listRowSeparator(.hidden)
  }

Он небольшой и легко читаемый. Он формирует представление заголовка, передавая различные состояния текущего представления в качестве биндинга к FeedsListTitleView.

Дальше .task(id:) используется для запуска нового поиска при обновлении поискового запроса, полученного из пользовательского ввода. Затем у нас есть два метода .onChange(of:): один для ресета плейсхолдера поиска, когда поисковый запрос пуст, и один для загрузки и установки пользовательских лент при обновлении environment свойства.

И это, по сути, позволяет создать простую настройку приложения, если мы возьмем весь сетап IcySky и немного упростим его:

@main
struct IcySkyApp: App {
  @Environment(\.scenePhase) var scenePhase

  @State var client: BSkyClient?
  @State var auth: Auth = .init()
  @State var currentUser: CurrentUser?
  @State var router: AppRouter = .init(initialTab: .feed)
  @State var isLoadingInitialSession: Bool = true
  @State var postDataControllerProvider: PostContextProvider = .init()

  var body: some Scene {
    WindowGroup {
      TabView(selection: $router.selectedTab) {
        if client != nil && currentUser != nil {
          ForEach(AppTab.allCases) { tab in
            AppTabRootView(tab: tab)
              .tag(tab)
              .toolbarVisibility(.hidden, for: .tabBar)
          }
        } else {
          ProgressView()
            .containerRelativeFrame([.horizontal, .vertical])
        }
      }
      .environment(client)
      .environment(currentUser)
      .environment(auth)
      .environment(router)
      .environment(postDataControllerProvider)
      .modelContainer(for: RecentFeedItem.self)
      .sheet(
        item: $router.presentedSheet,
        content: { presentedSheet in
          switch presentedSheet {
          case .auth:
            AuthView()
              .environment(auth)
          case let .fullScreenMedia(images, preloadedImage, namespace):
            FullScreenMediaView(
              images: images,
              preloadedImage: preloadedImage,
              namespace: namespace
            )
          }
        }
      )
      .task(id: auth.sessionLastRefreshed) {
        if let newConfiguration = auth.configuration {
          await refreshEnvWith(configuration: newConfiguration)
          if router.presentedSheet == .auth {
            router.presentedSheet = nil
          }
        } else if auth.configuration == nil && !isLoadingInitialSession {
          router.presentedSheet = .auth
        }
        isLoadingInitialSession = false
      }
      .task(id: scenePhase) {
        if scenePhase == .active {
          await auth.refresh()
        }
      }

Все Environment инициализируются при запуске приложения и внедряются в иерархию представлений. Поэтому их может получить любое представление. Если вы посмотрите на модификаторы task(id:), то увидите, что там я обрабатываю Auth. Состояние аутентификации приложения выводится из среды Auth и выражается на уровне приложения, создавая побочный эффект в роутере, который затем отображает или скрывает экран входа.

Ключевым моментом является правильное разделение ответственности:

Реальность тестирования

«Как это тестировать?» — вот следующий вопрос, который мне всегда задают.

Правда в том, что тестирование представлений SwiftUI дает минимальную ценность. Ваши представления должны быть настолько простыми, чтобы ошибки были сразу видны. Тестировать нужно именно ваши строительные блоки — сетевые клиенты, модели данных и бизнес-логику.

Если вы хотите тестировать свои представления, проводите автоматические тесты UI и сквозное тестирование.

Мой подход:

Иногда представление разрастается за пределы того, что удобно разместить в одном файле. Это сигнал к его разделению, а не к добавлению ViewModel. В IcySky у меня есть составные представления, например:

struct PostDetailView: View {
    let post: Post
    @State private var isExpanded: Bool = false
    
    
    var body: some View {
        ScrollView {
            LazyVStack(spacing: 0) {
                PostHeaderView(post: post)
                PostContentView(post: post)
                PostActionsView(post: post, isExpanded: $isExpanded)
                PostRepliesView(postId: post.id)
            }
        }
    }
}

Каждое подпредставление обрабатывает своё состояние и взаимодействия. Координации с ViewModel не требуется. Используйте State и Binding для управления потоком данных между представлениями.

Но если вы хотите протестировать своё представление с помощью интроспекции, я уже некоторое время использую отличную библиотеку ViewInspector.

Если вы хотите протестировать представление View не только с помощью снепшот теста, вы можете использовать ViewInspector. Например, вот как мы тестируем наш рендер текста уведомлений в приложении Medium для iOS.

@MainActor
final class ActivityTextBuilderTestsTests: XCTestCase {
  func test_follow_activity() async {
    let activity = GQL.Activity(notificationType: "users_following_you",
                                notificationName: UUID().uuidString,
                                isUnread: false,
                                occurredAt: 0,
                                actor: .init(id: "test", name: "Test user"))
    let build = ActivityTextBuilder(activity: activity)
    let text = try! build.buildActorText().inspect().text().string()
    XCTAssertEqual(text, "Test user started following you")
  }

  func test_catalog_recommend() async {
    let activity = GQL.Activity(notificationType: "catalog_recommended",
                                notificationName: UUID().uuidString,
                                isUnread: false,
                                occurredAt: 0,
                                actor: .init(id: "test", name: "Test user"),
                                catalog: .init(id: "test", name: "Test catalog"))
    let build = ActivityTextBuilder(activity: activity)
    let actorText = try! build.buildActorText().inspect().text().string()
    let contentText = try! build.buildContentText().inspect().text().string()
    XCTAssertEqual(actorText, "Test user clapped for")
    XCTAssertEqual(contentText, " Test catalog")
  }
}

Это полезно для подтверждения того, что создание представления с одним входом даёт ожидаемый и согласованный результат.

Реальность 2025 года

По мере того, как мы погружаемся в 2025 год, SwiftUI становится всё более зрелым. У нас есть @Observable, улучшенная обработка Environment, улучшенная поддержка асинхронности, жизненный цикл задач и т.д. Появились инструменты для создания сложных приложений без накладных расходов, связанных с ViewModel.

Почти все основные аспекты жизненного цикла View находятся на уровне самого View, и сложно строить что-либо вокруг View, потому что это означает необходимость передавать множество вещей за пределы самого представления.

Я пересмотрю подход к ViewModel в SwiftUI, когда/если Apple предоставит способ доступа к environment за пределами представления. Я знаю, что это можно сделать с помощью библиотеки вроде TCA, но для меня единственный канон — это обычный SwiftUI.

Итак, если бы можно было реализовать что-то вроде приведённого ниже кода, где с помощью макроса можно было бы связать класс с представлением и получить доступ к его внедрённому объекту среды и значениям, это открыло бы множество возможностей для создания ещё более чистого кода.

@Observable
@ObservableWithEnvironment<UserListView>
class UserListDataSource {
    @Environment(\.databaseService) var database
    
    var users: [String] = []
    
    func fetch() async - {
        self.users = await database.fetch()
    }
}

struct UserListView: View {
    @State private var dataSource = UserListDataSource()
    
    var body: some View {
        List(dataSource.users, id: \.self) { user in
            Text(user)
        }
        .task {
            await dataSource.fetch()
        }
    }
}

Почему это важно

Каждая добавляемая вами ViewModel:

ViewModel также являются лёгким способом раздуть приложение. Вы отлично проведёте время, если заставите себя разделить свои представления на максимально мелкие блоки. ViewModel, как и Controller в MVC, поощряет вас выносить весь код логики за пределы, чтобы он выглядел «чистым».

SwiftUI предоставляет вам мощные примитивы: @State, @Environment, @Observable, Bindable/Binding. Используйте их. Доверьтесь фреймворку. Ваше будущее будет вам благодарно.

Итог

В 2025 году нет оправданий загромождению ваших SwiftUI-приложений ненужными ViewModel. Примите во внимание архитектуру фреймворка. Пусть ваши представления будут простыми, чистыми выражениями состояния. Сосредоточьте тестирование и сложность на важных частях — вашей бизнес-логике и сервисах.

Ваши приложения SwiftUI станут чище, удобнее в поддержке и приятнее в работе.

Прощай, MVVM.

Да здравствует The View.

Счастливого кодинга.

Источник

Exit mobile version