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

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 архитектуру для реализации небольшого приложения. Регистрируйтесь по ссылке.