Или он просто странно пахнет?

Apple представила свой фреймворк Combine на WWDC 2019 вместе с iOS 13, macOS 10.15 и, возможно, что самое главное, SwiftUI. В то время мы все были очень взволнованы тем, что Apple предоставила нам собственное решение для реактивного программирования.

Год спустя, в октябре 2020 года, на форумах Swift появились первые роадмапы Swift Concurrency. И на следующем WWDC в 2021 году у нас уже был первый полный релиз Swift Concurrency… и никаких значимых обновлений для Combine. Перенесемся еще на год вперед к WWDC 2022 и снова никаких серьезных обновлений Combine. В результате всей этой эпопеи разработчики небезосновательно начали размышлять о том, мертв ли ​​Combine, забросила ли его Apple в угоду Swift Concurrency.

‍Итак, мертв ли Combine? Мы так не думаем!

Полагаемся ли мы на Combine в нашей кодовой базе все больше и больше? Да!

Используем ли мы при этом Swift Concurrency? Да!

Приведет ли это впоследствии к архитектурным проблемам, о которых мы будем очень сильно сожалеть? Надеюсь, что нет!

Давайте начнем с того, для чего был создан Combine (и в чем он действительно хорош): создание системы для реагирования на изменения с течением времени. Он позволяет нам легко добиться того, что мы не “упустим” обновление, если забудем где-нибудь уведомить о нем делегата. Он также отлично подходит для манипулирования этим потоком данных в динамике, заботясь о таких вещах, как дебаунсинг, удаление дубликатов, объединение или слияние значений и многом другом.

А в чем он не так хорош? Возможно, его самым слабым местом является то, как он интегрируется с другими решениями — в частности, Combine не очень хорошо работает со Swift Concurrency. Но, разбираясь, где и какой инструмент использовать (подробнее об этом чуть позже), можно легко избежать конфликтов между системами и заставить их хорошо работать в тандеме.

Итак, почему мы не считаем, что он умирает, и почему мы решили, что инвестировать в него безопасно? Нас не беспокоит отсутствие обновлений этого фреймворка по нескольким причинам:

  • Это по большей части полнофункциональная библиотека. Если вы посмотрите на стандартный API Rx-библиотек, то Combine уже позаботился об этом. Все стандартные операторы Rx в наличии и были там с момента его появления.

  • Он сильно связан со SwiftUI, который, как ясно дала понять Apple, является оптимальным способом создания новых приложений (да, пока это спорное заявление… но это уже совсем другая тема, которой следует посвятить отдельный пост).

  • Он учитывает сценарии, которые Swift Concurrency не предусматривает, например, наличие более одного подписчика на поток данных, объединение потоков и т. д.

Реагирование на изменения не является какой-то новой задачей для программистов, и за все время было создано бесчисленное множество систем для решения этой задачи. В экосистеме Apple самым долгоживущим и заметным решением является KVO. Что касается сторонних решений, то одним из самых актуальных фреймворков является RxSwift (который также используется в нашей кодовой базе… и от которого мы постепенно отказываемся).

До недавних рефакторингов наш код был пронизан KVO. В частности, он использовался для наблюдения за слоем модели ссылочного типа и реагирования на обновления слоя пользовательского интерфейса.

Вот пример того, как это выглядело:

func setupObservations() {
  updateOnChangeOf(user, \.conversation.isSharingCamera)
  updateOnChangeOf(user, \.conversation.isSharingScreen)
  updateOnChangeOf(user, \.conversation.conversation.conversationType)
  updateOnChangeOf(user, \.currentRoom)
  //etc.
}

func updateOnChangeOf<T: NSObject, S>(_ target: T, _ path: KeyPath<T, S>, additional: (() -> Void)? = nil) -> NSKeyValueObservation {
  return addObservation(target.observe(path) { [weak self] _, _ in
    additional?()
      self?.needsUpdate = true
  })
}

Ток почему же мы хотели избавиться от этого? В конце концов, это решение было (по большей части) рабочим. Но одна из важных причин отказаться от него заключалась в том, что мы хотели меньше полагаться на рантайм Objective-C и предпочитаемые им типы данных. Чтобы задействовать KVO, ваши модели должны быть ссылочными типами и, если они написаны на Swift, аннотированны большим количеством @objc. Swift, как правило, предпочитает структуры данных значимых типов, и, хоть мы не рассматриваем возможность перехода в ближайшем будущем, эта зависимость от KVO может стать серьезным препятствием. Кроме того, несмотря на то, что Remotion по большей части является AppKit-приложением, то, что мы добавляем все больше и больше SwiftUI, NSObject и @objc-аннотаций никак не помогает нам в мире SwiftUI. Но даже если наши модели не берут за основу структуры, Combine’овые @Published-модели очень хорошо работают со SwiftUI.

Итак, как теперь выглядит наш код?

func setupObservations() {
  updateOnChangeOf(user.conversation.objectWillChange)
  updateOnChangeOf(user.$currentRoom)
  //etc.
}

func updateOnChangeOf<T: Publisher>(_ publisher: T) where T.Failure == Never, T.Output: Equatable { publisher
   .removeDuplicates()
    .sink { [weak self] newValue in
      self?.needsUpdate = true
    }
    .store(in: &cancellableSet)
}

Лучше, не правда ли? На самом деле, в некотором смысле, этот код выглядит даже сложнее! Но это зависит от того, как посмотреть — в первом примере не показаны все @objc, NSObject и KVO-колбеки, которые нам впоследствии удалось удалить. В конечном итоге пулл-реквест с заменой KVO на Combine в нашем приложении продемонстрировал +2000/-2400 строк. Всегда приятно настолько уменьшить количество строк в коде! Кроме того, обратите внимание на .removeDuplicates — теперь нам доступно много интересных возможностей, которые мы можем реализовать с помощью дополнительных операторов в этой цепочке (например, throttle), что является огромным преимуществом.

По пути мы столкнулись с парой подводных камней, самый большой из которых — использование willSet для @Published-свойств в Combine в противовес использованиею didSet в KVO.

Чтобы проиллюстрировать это, давайте рассмотрим следующий сценарий:

onChangeOf(model, \.isActive) { //KVO, using `didSet`
  updateUI()
}

func updateUI() {  
  toggle.isOn = model.isActive
}

Обратите внимание, что в этом сценарии, чтобы пользовательский интерфейс был синхронизирован с моделью, model.isActive должен быть установлен перед вызовом updateUI .

Но что, если мы сделаем так?

model.$isActive.sink { [weak self] _ in self?.updateUI() }

Ну, поскольку @Published-свойства срабатывают на willSet вместо didSet, к тому времени, когда updateUI запустится и проверит значение model.isActive, оно все еще может быть *предыдущим*. Каковы решения этого? Например, вы можете передать зависимое состояние нижестоящим функциям, как-то так:

func updateUI(isActive: Bool) {
  toggle.isActive = isActive
}

Еще одним решением является использование оператора Combine для задержки sink до следующего цикла выполнения:

model.$isActive
  .receive(on: DispatchQueue.main)
  .sink {

Оператор receive(on:) будет ожидать следующего цикла выполнения, даже если мы уже находимся в main queue. Поскольку Publisher был запущен для willSet, к концу предыдущего цикла выполнения значение будет установлено, и мы cможем обратиться к model.isActive в updateUI снова.

Все это звучит как-то чересчур сложно? Так и есть! Легко ли попасть в ситуацию, когда ваш пользовательский интерфейс не синхронизирован с вашей моделью? Вполне возможно. Одна из опасностей заключается в том, что это ложится не плечи разработчиков необходимостью писать код определенным образом, а не зависит от статической проверки компилятором. По мере того, как мы все больше движемся в сторону SwiftUI, мы движемся к тому, что мы считаем более надежной связью состояний модели и пользовательского интерфейса:

struct MyView: View {
  @Binding var model: Model

  var body: some View {
    Toggle(isOn: $model.isOn)
  } 
}

Здесь нам вообще не нужно беспокоиться о willSet и didSet — мы знаем, что если наша модель имеет определенное состояние, наше представление точно будет отражать его.

Если отставить в сторону подводные камни, подобные продемонстрированному выше, по мере нашей работы, мы добавляли Combine во все больше и больше областей нашей кодовой базы (на самом деле, это даже стало локальным мемом между мной и еще одним инженером в нашей команде — на прошлой неделе я поймал себя на том, что говорю: “Знаешь, в конце концов у нас все-таки будет ошибка, которую я не смогу исправить с помощью Combine”). В частности, мы заменяем все больше и больше инфраструктуры, которая транслирует или потребляет ‘состояние’ любого типа. Например, в прошлом вы могли наткнуться на наш пост о модуляции громкости музыки во время разговоров. Это решение реализовано с помощью Combine.

Еще одна область, с которой мы только начали экспериментировать, — это преобразование нашей сложно-вложенной структуры моделей ссылочных типов во что-то, что легче читать из нашего слоя пользовательского интерфейса, который, как я упоминал ранее, содержит все больше и больше SwiftUI. Вот пример того, как выглядит часть нашего текущего слоя модели (его очень абстрактная версия):

class User: Identifiable, ObservableObject {
  var id: UUID
  @Published var conversation: Conversation?
}

class Conversation: Identifiable {
  var id: UUID
  var currentUserID: User.ID
  @Published var otherUsers: [User.ID: User]
  @Published var conversationMedia: ConversationMedia?
}

class ConversationMedia: ObservableObject {
  @Published var isCurrentUserStreaming: Bool
  @Published var otherUsersStreamingStates: [UserID: StreamingState]
}

Это создает некоторые проблемы на пути к эффективному мониторингу, особенно потому, что некоторые из вложенных свойств (например, Conversation и ConversationMedia) являются optional (эти объекты, кстати, до нашего перехода с KVO были бы NSObject’ами со свойствами, которые мониторит KVO). SwiftUI не справляется с этой структурой. Несмотря на то, что все это является ObservableObject’ами, поскольку вложенные ObservableObject’ы не работают без ручного подключения objectWillChange, такие вещи не будут работать из коробки:

struct ConversationView: View {
  @ObservedObject var conversation: Conversation

  var body: some View {
    HStack {
      Text("Streaming?")
      if conversation.conversationMedia.isCurrentUserStreaming {
       Image(systemName: "record.circle")
      }
    }
  } 
}

Но если бы все модели были структурами, а не объектами, то это бы работало. Так что же нам нужно сделать, чтобы эта трансляция состоялась, и мы были уверены, что все обновляется? Что-то вроде этого:

struct TranslatedUser: Identifiable {
  var id: UUID
  var conversationId: TranslatedConversation.ID?
  var streamingState: StreamingState
}

struct TranslatedConversation: Identifiable {
  var id: UUID
  var currentUserID: User.ID
  var otherUsers: [User.ID]
}

func translateModels() {
  usersController
     .currentUser // старый User class
     .$conversation
     .map { conversation in
       conversation?.conversation.$media.eraseToAnyPublisher() ?? Just(nil).eraseToAnyPublisher()
     }
     .switchToLatest()
     .map { conversationMedia in
       conversationMedia?.$otherUsersStreamingStates.eraseToAnyPublisher() ?? Just([:]).eraseToAnyPublisher()
     }
     .switchToLatest()
     .sink { [weak self] streamingStates in
        streamingStates.forEach { id, state in
            //модифицируем наши новые транслированные модели
            self?.users[id]?.streamingState = state
        }
     .store(in: &cancellableSet)
}

struct ConversationView: View {
  var user: TranslatedUser

  var body: some View {
    HStack {
      Text("Streaming?")
      if user.streamingState == .streaming {
       Image(systemName: "record.circle")
      }
    }
  } 
}

Это, безусловно, не так просто реализовать и поддерживать, но это позволяет нам получить достаточно простую модель состояния с точки зрения пользовательского интерфейса. Это также делает отдельные компоненты относительно тестируемыми! Замечу, кстати, что приведенный выше сценарий — это как раз то, что Swift Concurrency абсолютно не способен решить — это задача, которая как раз по плечу Combine.

В общем, мы не думаем, что Combine скоро исчезнет. Мы думаем, что это фантастический инструмент для решения определенных типов задач. Используете ли вы Combine в своих проектах? Мы хотели бы услышать ваш опыт! Твитните @jnpdx или @remotionco ваши соображения.


Завтра вечером состоится бесплатное занятие «Пример реализации технологии Flux на SwiftUI». На уроке рассмотрим некоторые проблемы и сложности реализации MVVM на SwiftUI. Также попробуем применить Flux архитектуру для реализации небольшого приложения. Регистрируйтесь по ссылке.

Комментарии (1)


  1. t-nick
    03.11.2022 00:36

    Вместо ожидаемого сравнения с Swift Concurrency чувак зачем-то ушел в KVO.