Site icon AppTractor

Убираем 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 — это самый важный момент в этой истории, поскольку здесь я определяю различные состояния, которые будут отображаться в представлении непосредственно внутри него. Начальное состояние — это загрузка.

Теперь давайте посмотрим, как это представление рисуется на экране:

  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 🚀.

Источник

Exit mobile version