Разработка
Убираем M из MVVM в SwiftUI
Главное — свести код представления к минимуму, а как только он становится слишком большим для одного представления — разделить его!
Я так часто сталкиваюсь с этим вопросом, что наконец-то хочу написать о нем. Это не будет длинный пост об архитектуре iOS-приложений, и это даже не будет провокационный быстрый комментарий. Это просто то, как я создаю iOS-приложения в эти дни, особенно Ice Cubes, мой SwiftUI клиент Mastodon с открытым исходным кодом. Если вы достаточно хорошо инкапсулируете свой код, ваши View — это просто представления состояний, не меньше и не больше.
Недавно я добавил новую функцию, и благодаря всем инструментам, которые я уже собрал в Ice Cubes, мне потребовалась всего пара часов, чтобы соединить все вместе. Речь идет о поддержке новой функции фильтрации уведомлений, которую Mastodon добавил в свой бэкэнд и веб-фронтэнд. В этой статье я не буду демонстрировать функцию, поскольку для нас интересен код.
При написании кода для этой функции я фактически вернулся к кодовой базе после нескольких недель, в течение которых я к ней не прикасался. Одной из целей было свести этот код к абсолютному минимуму. Это один из единственных способов сохранить рассудок при работе над моими проектами с открытым исходным кодом. Не поймите меня неправильно, я люблю это занятие, но с учетом рабочей и семейной жизни сведение лишнего кода к минимуму доставляет массу удовольствия.
Сначала мы рассмотрим список запросов на уведомления. Это представление со списком запросов, с которыми пользователь может взаимодействовать. Давайте посмотрим, как я создал его без использования какой-либо модели представления.
public struct NotificationsRequestsListView: View {
@Environment(Client.self) private var client
@Environment(Theme.self) private var theme
enum ViewState {
case loading
case error
case requests(_ data: [NotificationsRequest])
}
@State private var viewState: ViewState = .loading
Первые две строки посвящены получению двух сред — первая Client
предоставляет представлению все необходимые высокоуровневые функции для выполнения сетевых запросов к Mastodon. Вторая Theme
предлагает все необходимое для настройки внешнего вида представления.
Затем я определяю ViewState
— это самый важный момент в этой истории, поскольку здесь я определяю различные состояния, которые будут отображаться в представлении непосредственно внутри него. Начальное состояние — это загрузка.
Теперь давайте посмотрим, как это представление рисуется на экране:
xxxxxxxxxx
public var body: some View {
List {
switch viewState {
case .loading:
ProgressView()
.listSectionSeparator(.hidden)
case .error:
ErrorView(title: "notifications.error.title",
message: "notifications.error.message",
buttonTitle: "action.retry")
{
await fetchRequests()
}
.listSectionSeparator(.hidden)
case let .requests(data):
ForEach(data) { request in
NotificationsRequestsRowView(request: request)
.swipeActions {
Button {
Task { await acceptRequest(request) }
} label: {
Label("account.follow-request.accept", systemImage: "checkmark")
}
Button {
Task { await dismissRequest(request) }
} label: {
Label("account.follow-request.reject", systemImage: "xmark")
}
.tint(.red)
}
}
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(theme.primaryBackgroundColor)
.navigationTitle("notifications.content-filter.requests.title")
.navigationBarTitleDisplayMode(.inline)
.task {
await fetchRequests()
}
.refreshable {
await fetchRequests()
}
}
Вы наверняка заметили, насколько коротким получился код для представления, показывающего три разных состояния. Это потому, что один из лучших способов убрать «М» из MVVM — разбить представление на маленькие, простые структуры. Apple построила SwiftUI именно таким образом: State
, Binding
и Environment
позволяют вашим представлениям взаимодействовать друг с другом прямолинейно.
Когда представление загружается, я использую встроенный ProgressView. Если возникает ошибка, я использую свой собственный ErrorView, который предоставляет кнопку для повторной попытки. Наконец, если есть данные, я отображаю их с помощью другого представления, и я добавил несколько жестов пролистывания, чтобы пользователь мог взаимодействовать с запросами уведомлений.
Последняя часть этого представления содержит некоторые функции синхронизации, которые это представление будет использовать. Код хорошо инкапсулирован, поэтому его наличие в представлении абсолютно нормально. Client
высокоуровневый и тестируется независимо, поэтому наличие однострочных запросов на уровне представления не является проблемой.
xxxxxxxxxx
private func fetchRequests() async {
do {
viewState = .requests(try await client.get(endpoint: Notifications.requests))
} catch {
viewState = .error
}
}
private func acceptRequest(_ request: NotificationsRequest) async {
_ = try? await client.post(endpoint: Notifications.acceptRequest(id: request.id))
await fetchRequests()
}
private func dismissRequest(_ request: NotificationsRequest) async {
_ = try? await client.post(endpoint: Notifications.dismissRequest(id: request.id))
await fetchRequests()
}
Некоторые улучшения могут заключаться в добавлении управления ошибками в функции acceptRequest
и dismissRequest
. Если запрос не удается, пользователю может быть представлено предупреждение, чтобы он мог повторить попытку. Мы также могли бы сделать некоторое оптимистичное поведение и удалять запросы из текущих отображаемых, как только пользователь провзаимодействует с ними. И чтобы было понятно, весь этот код по-прежнему будет находиться в этом представлении.
Теперь давайте посмотрим на NotificationsRequestsRowView
, это чистое представление без особого взаимодействия. Но это еще один пример представления с чистым состоянием. В это представление передается статический объект, и единственная цель этого представления — отобразить его.
xxxxxxxxxx
struct NotificationsRequestsRowView: View {
@Environment(Theme.self) private var theme
@Environment(RouterPath.self) private var routerPath
@Environment(Client.self) private var client
let request: NotificationsRequest
var body: some View {
HStack(alignment: .center, spacing: 8) {
AvatarView(request.account.avatar, config: .embed)
VStack(alignment: .leading) {
EmojiTextApp(request.account.cachedDisplayName, emojis: request.account.emojis)
.font(.scaledBody)
.foregroundStyle(theme.labelColor)
.lineLimit(1)
Text(request.account.acct)
.font(.scaledFootnote)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.padding(.vertical, 4)
Spacer()
Text(request.notificationsCount)
.font(.footnote)
.monospacedDigit()
.foregroundStyle(theme.primaryBackgroundColor)
.padding(8)
.background(.secondary)
.clipShape(Circle())
Image(systemName: "chevron.right")
.foregroundStyle(.secondary)
}
.onTapGesture {
routerPath.navigate(to: .notificationForAccount(accountId: request.account.id))
}
.listRowInsets(.init(top: 12,
leading: .layoutPadding,
bottom: 12,
trailing: .layoutPadding))
.listRowBackground(theme.primaryBackgroundColor)
}
}
Теперь давайте перейдем к вопросам, которые люди задают мне снова и снова.
Как вы это тестируете?
Какое значение имеет тестирование представлений для вашего проекта? Что вы хотите протестировать? Поскольку ваши представления — это просто выражения состояния, лучшие тесты — это snapshot тесты. Если вы создали превью для ваших представлений, то тестирование моментальных снимков — это, по сути, то же самое. Введите состояние в представление, сделайте его скриншот и сравните этот скриншот с новым в следующий раз, когда захотите его протестировать.
При модульном тестировании следует тестировать все ваши строительные блоки, в моем случае Client и Theme, например. Если они работают правильно, то и мое View будет работать правильно.
А с окружениями можно легко построить их таким образом, чтобы вы могли внедрять различные реализации для превью, тестов, продакшена и т.д… Окружения — это буквально бесплатная инъекция зависимостей для всей иерархии представлений.
Но этот паттерн работает только для простых REST-приложений…
Не совсем. Конечно, Ice Cubes — это клиент Mastodon, но он также имеет тонну функций на стороне клиента, широко использует SwiftData и предоставляет множество функций, не связанных с API Mastodon. Главное — свести код представления к минимуму, а как только он становится слишком большим для одного представления — разделить его!
Прощай MVVM, слава VV 🚀.
-
Новости4 недели назад
Видео и подкасты о мобильной разработке 2025.11
-
Новости1 неделя назад
Видео и подкасты о мобильной разработке 2025.14
-
Видео и подкасты для разработчиков3 недели назад
Javascript для бэкенда – отличная идея: Node.js, NPM, Typescript
-
Новости3 недели назад
Видео и подкасты о мобильной разработке 2025.12