Я так часто сталкиваюсь с этим вопросом, что наконец-то хочу написать о нем. Это не будет длинный пост об архитектуре 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
— это самый важный момент в этой истории, поскольку здесь я определяю различные состояния, которые будут отображаться в представлении непосредственно внутри него. Начальное состояние — это загрузка.
Теперь давайте посмотрим, как это представление рисуется на экране:
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
высокоуровневый и тестируется независимо, поэтому наличие однострочных запросов на уровне представления не является проблемой.
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
, это чистое представление без особого взаимодействия. Но это еще один пример представления с чистым состоянием. В это представление передается статический объект, и единственная цель этого представления — отобразить его.
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 🚀.