Современный SwiftUI: общение родителей и детей

01 февраля 2023

Преамбула

 

Этот пост в блоге является одним из серии. Обязательно прочитайте все части!

  • Modern SwiftUI: Parent-child communication
  • Modern SwiftUI: Identified arrays
  • Modern SwiftUI: State-driven navigation
  • Modern SwiftUI: Dependencies
  • Modern SwiftUI: Testing

На прошлой неделе мы завершили нашу амбициозную серию из 7 статей, посвященных современным передовым методам разработки SwiftUI. В этих эпизодах мы перестроили приложение компании Apple “Scrumdinger” с нуля (исходный код здесь), которое является отличной демонстрацией многих проблем, с которыми можно столкнуться в реальном приложении. На каждом этапе пути мы ставили перед собой задачу написать код максимально масштабируемым и ориентированным на будущее, в том числе:

  1. Мы избегаем простых массивов для списков и вместо этого используем идентифицированные массивы (identified arrays).
  2. Вся навигация управляется состоянием и лаконично смоделирована.
  3. Все побочные эффекты и зависимости контролируются.
  4. Предоставляется полный набор тестов для тестирования многих сложных и нюансированных пользовательских процессов.

… и еще куча всего.

Чтобы отпраздновать завершение этой серии, мы собираемся каждый день на этой неделе публиковать по одной новой записи в блоге, в которой подробно описывается область разработки SwiftUI, которую можно модернизировать, начиная с сегодняшнего дня — общения родителей и детей.

Если вы найдёте что-то из этого интересным, рассмотрите возможность подписки сегодня, чтобы получить доступ к полной серии, а также ко всему нашему каталогу эпизодов!

 

Общение между родителями и детьми

Обычно сложное view (представление) разбивается на более мелкие части. Даже такая простая вещь, как отображение листа, обычно выполняется с помощью специального view. Например, список строк, при нажатии на одну из которых открывается лист для редактирования:

struct StandupsList: View {
  @State var standups: [Standup] = []
  @State var editStandup: Standup?

  var body: some View {
    List {
      ForEach(self.standup) { standup in
        Button(standup.title) { self.editStandup = standup }
      }
    }
    .sheet(item: self.$editStandup) { standup in
      EditStandup(standup: standup)
    }
  }
}

struct EditStandup: View {
  let standup: Standup

  var body: some View {
    Form {
      …
    }
  }
}

 

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

Самый простой способ добиться этого — использовать “delegate closures” (делегированные замыкания). То есть дочернее view, в данном случае EditStandup, может предоставлять замыкание, которое вызывается всякий раз, когда внутри дочернего view происходит какое-либо событие, а родитель может переопределить это замыкание. Мы называем это “delegate closure” (замыканием делегата), потому что оно напоминает шаблон делегата, который популярен в UIKit.

Например, предположим, что в EditStandup есть кнопка удаления, и при её нажатии эта строка должна быть удалена. View редактирования не может выполнить логику удаления, поскольку у него нет доступа к полному массиву стендапов. Эти данные есть только в домене списка стендапов.
Таким образом, view может удерживать замыкание, которое оно будет вызывать всякий раз, когда захочет сообщить родителю, чтобы он выполнил реальную логику удаления:

struct EditStandup: View {
  let standup: Standup
  let onDeleteButtonTapped: () -> Void

  var body: some View {
    Form {
      …
      Button("Delete") {
        self.onDeleteButtonTapped()
      }
    }
  }
}

 

Мы предпочитаем называть эти замыкания так, чтобы они начинались с on*, а затем точно описывали, какое действие выполнил пользователь (например, onDeleteButtonTapped), а не в соответствии с тем, что, по мнению дочернего элемента, должен сделать родитель (например, deleteStandup). Это позволяет родительскому домену легко узнать, что именно произошло внутри view, и он может реализовать любую логику, которую захочет.

Затем, когда родительское view (StandupsList) создаст дочернее view (EditStandup), оно предоставит замыкание для onDeleteButtonTapped, и в этом замыкании для родительского домена уместно реализовать логику удаления:

struct StandupsList: View {
  @State var standups: [Standup] = []
  @State var editStandup: Standup?

  var body: some View {
    List {
      …
    }
    .sheet(item: self.$editStandup) { standup in
      EditStandup(standup: standup) {
        // ✅ Perform logic when "Delete" button is tapped in sheet
        self.standups.removeAll { $0 == standup }
      }
    }
  }
}

 

Это просто и отлично работает на практике.

Однако не всегда целесообразно выполнять всю эту логику во view. Прямо сейчас этот код можно протестировать только в тесте пользовательского интерфейса, который может быть медленным и ненадёжным. И в будущем дочернему домену может потребоваться выполнить свою собственную сложную логику, прежде чем сообщить родителю об удалении (например, отслеживание аналитики или выполнение запросов API), и поэтому нам может понадобиться переместить поведение в ObservableObject для каждого дочернего и родительских доменов.

 

Общение между родительскими и дочерними объектами ObservableObject

Но всё становится сложнее, когда необходимо выразить отношения родитель-потомок между ObservableObjects, а не между view. Вы можете сделать это, если логика и поведение внутри view редактирования и списка становятся слишком сложными, чтобы все было во view.

Например, мы могли бы определить ObservableObject для домена “edit standup” (редактировать стендап), который удерживает стендап и имеет конечную точку, которая вызывается при нажатии кнопки удаления: 

class EditStandupModel: ObservableObject {
  @Published var standup: Standup

  func deleteButtonTapped() {
  }

  …
}

 

И мы можем определить ObservableObject для домена “standup list”, который содержит необязательный EditStandupModel, который устанавливается, когда лист должен быть представлен:

class StandupListsModel: ObservableObject {
  @Published var standups: [Standup] = []
  @Published var editStandup: EditStandupModel?

  func standupTapped(standup: Standup) {
    self.editStandup = EditStandupModel(standup: standup)
  }

  …
}

 

Теперь нам нужен какой-то способ, чтобы эти два объекта взаимодействовали друг с другом. Мы можем попробовать повторить шаблон для view, добавив замыкание обратного вызова делегата в дочерний домен:

class EditStandupModel: ObservableObject {
  @Published var standup: Standup
  var onDeleteButtonTapped: () -> Void

  func deleteButtonTapped() {
    // Let the parent know that the delete button was tapped.
    self.onDeleteButtonTapped()
  }

  …
}

 

И затем при создании EditStandupModel мы можем предоставить замыкание, чтобы реализовать логику нажатия кнопки удаления, уделяя особое внимание тому, чтобы не создавать цикл сохранения, поскольку теперь мы имеем дело со ссылочными типами:

func standupTapped(standup: Standup) {
  let model = EditStandupModel(standup: standup) { [weak self] _ in
    guard let self
    else { return }
    self.standups.remove { $0 == standup }
  }
  self.editStandup = model
}

 

Это работает, но это также немного странно. В том виде, как это разработано сейчас, мы должны быть готовы обеспечить замыкание в любое время при создании EditStandupModel, а это не всегда может быть удобно или даже возможно.

Например, если мы хотим запустить приложение в состоянии, в котором представлен лист редактирования, это должно быть так же просто, как создание модели StandupsListModel с заполненным состоянием editStandup, но, к сожалению, мы также должны обеспечить замыкание удаления:

@main
struct StandupsApp: App {
  var body: some Scene {
    WindowGroup {
      StandupsList(
        model: StandupsListModel(
          standups: [
            …
          ],
          editStandup: EditStandupModel(
            standup: standup,
            onDeleteButtonTapped: () -> Void  // ???
          )
        )
      )
    }
  }
}

 

Однако реализовать эту логику здесь невозможно. Только StandupsListModel может реализовать эту логику. И это только верхушка айсберга. Мы неоднократно столкнёмся с необходимостью создать EditStandupModel, для которого невозможно немедленно обеспечить замыкание удаления.

Альтернативный подход — указать значение по умолчанию для замыкания, чтобы можно было создать EditStandupModel без замыкания:

class EditStandupModel: ObservableObject {
  @Published var standup: Standup
  var onDeleteButtonTapped: () -> Void = {}

  …
}

 

… и затем вы привязываете замыкание позже. Делать это необходимо в двух ситуациях. При изменении состояния editStandup и при создании родительского домена:

class StandupListsModel: ObservableObject {
  @Published var standups: [Standup] = []
  @Published var editStandup: EditStandupModel? {
    didSet { self.bind() }  // 👈
  }

  init(
    standups: [Standup] = [],
    editStandup: EditStandupModel? = nil
  ) {
    self.standups = standups
    self.editStandup = editStandup
    self.bind()  // 👈
  }

  // Override delegate closures in all child models.
  private func bind() {
    self.editStandup?.onDeleteButtonTapped = { [weak self] in
      guard let self
      else { return }
      self.standups.remove { $0 == self.editStandup?.standup }
    }
  }
}

 

При этом вы можете создавать объекты EditStandupModel, не предоставляя замыкание, но замыкание по-прежнему будет должным образом привязано внутри родительского домена.

Итак, звучит как беспроигрышный вариант!

 

Безопасность против эргономики

Ну, не так быстро. Мы фактически потеряли некоторую безопасность с этим подходом.

Когда мы требовали замыкания при инициализации EditStandupModel, мы могли гарантировать, что замыкание будет предоставлено, просто это было не очень удобно. Но теперь, когда мы предоставили значение по умолчанию, вы можете создать EditStandupModel и не знать, что вам необходимо предоставить эту дополнительную функциональность.

Итак, если в будущем домен “edit standup” (редактировать стендап) получит новую функцию для дублирования стендапа, и ему необходимо будет сообщить об этом родителю:

class EditStandupModel: ObservableObject {
  var onDeleteButtonTapped: () -> Void = {}
  var onDuplicateButtonTapped: () -> Void = {}
  …
}


… тогда нам необходимо не забыть обновить метод привязки, чтобы использовать это новое замыкание. Ничто не даст нам понять, что это необходимо, а если мы забудем, то наша фича просто будет немного нарушена, и нам нужно будет рыться в коде, чтобы выяснить, что пошло не так.
Таким образом, требуется сделать выбор: хотим ли мы безопасности необходимого замыкания делегата, не имея наилучшей эргономики, или мы хотим эргономики за счёт потери некоторой безопасности?

 

“Нереализованные” замыкания делегатов

Что ж, к счастью для нас, есть золотая середина. Мы можем обеспечить безопасность и эргономику, используя то, что нам нравится называть “unimplemented delegate closures” (нереализованное закрытие делегата). Идея состоит в том, чтобы обеспечить замыкание по умолчанию, чтобы оно не требовалось во время инициализации, но издавало громкий шум при вызове замыкания. Это позволяет легко получать уведомления, когда вы неправильно настроили модель.

Сила этого подхода во многом зависит от того, как именно вы издаете “громкий шум”. Если вы печатаете сообщение только на консоли, оно будет недостаточно громким, и его будет легко пропустить. С другой стороны, если вы выполнили fatalError (фатальная ошибка), то это было бы очень громко, но не мешало бы вашему рабочему процессу.

Лучший подход — показать фиолетовое предупреждение во время выполнения в Xcode, очень похожее на то, что отображается, когда средство очистки потока обнаруживает проблему или когда вы обновляете пользовательский интерфейс в неосновном потоке. Мы писали об этом подходе в прошлом, и у нас даже есть библиотека с открытым исходным кодом, в которой есть инструмент, позволяющий сделать это суперэргономичным.

Инструмент называется нереализованным, и он способен генерировать замыкание практически любой сигнатуры, а если он когда-либо будет вызван, то вызовет предупреждение во время выполнения в Xcode и даже сбой теста при запуске в тестах:

import XCTestDynamicOverlay

class EditStandupModel: ObservableObject {
  var onDeleteButtonTapped: () -> Void = unimplemented(
    "EditStandupModel.onDeleteButtonTapped"
  )
  var onDuplicateButtonTapped: () -> Void = unimplemented(
    "EditStandupModel.onDuplicateButtonTapped"
  )
  …
}
 


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

Предупреждение даже даёт вам трассировку стека того, как что-то пошло не так, что действует как хлебные крошки для отслеживания проблемной строки кода:

Фу бар

Отсюда очевидно, что EditStandupModel имеет замыкание onDeleteButtonTapped, которое нам необходимо переопределить.

 

До скорого…

Это всё на данный момент. Мы надеемся, что вы узнали что-то об общении родитель-потомок с помощью ObservableObjects, и надеемся, что вы попробуете сделать такое общение более безопасным и эргономичным, используя “unimplemented delegate closures” (нереализованное закрытие делегата).

Возвращайтесь завтра ко второй части нашей серии блогов “Modern SwiftU”, где мы покажем, как сделать коллекции более безопасными и эффективными для использования в списках SwiftUI.

Оригинал статьи

Содержание