На дворе 2025 год, а мне всё ещё задают один и тот же вопрос: «Где ваши ViewModel?» Каждый раз, когда я делюсь своим мнением или кодом своих открытых проектов, таких как клиент BlueSky IcySky или даже приложение Medium для iOS, разработчики удивляются, видя чистые, простые представления без единой ViewModel.
Позвольте мне прояснить: вам не нужны ViewModel в SwiftUI.
Никогда не были нужны.
И никогда не будут нужны.
Ловушка MVVM
Когда SwiftUI был запущен в 2019 году, многие разработчики принесли с собой багаж UIKit. Мы настолько привыкли к проблеме Massive View Controller (MVC), что сразу же обратились к MVVM как к спасителю. Но дело в том, что SwiftUI — это не UIKit. Он был разработан с нуля с другой философией.
И эта философия подчёркивается в различных видеороликах WWDC от Apple:
- Data Flow Through SwiftUI — WWDC19
- Discover Observation in SwiftUI — WWDC23
- Data Essentials in SwiftUI — WWDC20
Всё это очень хорошие доклады, и в них почти не упоминается 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 и выражается на уровне приложения, создавая побочный эффект в роутере, который затем отображает или скрывает экран входа.
Ключевым моментом является правильное разделение ответственности:
- Модели: ваши структуры данных и бизнес-логика
- Сервисы: сетевые клиенты, базы данных, утилиты (внедряемые через Environment)
- Представления: чистые представления состояний, которые организуют взаимодействие с пользователем
Реальность тестирования
«Как это тестировать?» — вот следующий вопрос, который мне всегда задают.
Правда в том, что тестирование представлений SwiftUI дает минимальную ценность. Ваши представления должны быть настолько простыми, чтобы ошибки были сразу видны. Тестировать нужно именно ваши строительные блоки — сетевые клиенты, модели данных и бизнес-логику.
Если вы хотите тестировать свои представления, проводите автоматические тесты UI и сквозное тестирование.
Мой подход:
- Тестировать
BlueSkyClientс помощью модульных тестов - Тестировать модели данных и трансформации
- Использовать предварительные просмотры SwiftUI для визуального регрессионного тестирования
- Пусть ваши представления будут простыми выражениями состояния
Иногда представление разрастается за пределы того, что удобно разместить в одном файле. Это сигнал к его разделению, а не к добавлению 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.
Счастливого кодинга.
Источник

