В данной статье рассмотрим один из способов работы со сложностью, возникающей в ходе разработки ПО. Рассмотрим принципы SSOT, FRP (Combine), SRP и дойдём до архитектурного шаблона «Мрак в Моделях» (далее MM), являющегося комбинацией этих принципов. Примеры будут для iOS на Swift, но всё описанное, конечно, применимо не только на платформах Apple.


Часть 1. Как я пришёл к описываемому архитектурному шаблону




1.1. Разработка без комплексов, или архитектурный антишаблон «Massive View Controller»


Многие в iOS начинали свой путь с размещения практически всего кода в UIViewController'ах, т.к. любой экран в iOS есть ни что иное, как экземпляр UIViewController. Так куда класть код, если не в этот самый видимый экран? Кнопки-то ведь на экране? Следовательно, и реакции на кнопки должны быть там же. С этого и начнём.


Создадим крошечное приложение, позволяющее начинать звонок двумя способами:


  • ввести номер в поле ввода и нажать на кнопку «Начать звонок»
  • принять входящий звонок как аудио через интерфейс CallKit

Выглядеть оно будет минималистично:



 
В данном примере мы будем симулировать звонок, т.к. для его настоящего осуществления нам бы понадобился портал Apple Developer, работа с сертификатами и прочее, что не является предметом настоящей статьи.
 

1.1.1. Класс VideoCallSimulation


VideoCallSimulation является тёмной областью на экране приложения, где можно увидеть текущий статус: номер звонка либо N/A, обозначающее его отсутствие.


import UIKit

class VideoCallSimulation: UILabel {
  override init(frame: CGRect) {
    super.init(frame: frame)

    backgroundColor = .darkGray
    textColor = .white
    refreshUI(nil)
  }

  @available(*, unavailable)
  required init?(coder: NSCoder) { return nil }

  func startCall(callId: String) {
    refreshUI(callId)
  }

  private func refreshUI(_ callId: String?) {
    let status = callId ?? "N/A"
    text = "VideoCallSimulation status: '\(status)'"
  }
}

Пока ничего интересного.


1.1.2. Класс VoIPPushSimulation


VoIPPushSimulation симулирует получение в фоне (когда устройство заблокировано) уведомления (якобы VoIP push), на которое мы будем реагировать для отображения CallKit.


import UIKit

protocol VoIPPushSimulationDelegate {
  func voipPushSimulationDidReceivePayload(_ payload: String)
}

class VoIPPushSimulation {
  var delegate: VoIPPushSimulationDelegate?

  func simulate(
    payload: String,
    after delay: DispatchTimeInterval
  ) {
    let bgId = UIApplication.shared.beginBackgroundTask(expirationHandler: nil)
    // Прямо с Kodeco такой ужасный пример обращения к UIApplication:
    // https://www.kodeco.com/1276414-callkit-tutorial-for-ios
    DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
      self?.delegate?.voipPushSimulationDidReceivePayload(payload)
      UIApplication.shared.endBackgroundTask(bgId)
    }
  }
}

Из интересного тут лишь кривое обращение к UIApplication, но у нас ведь пока нет комплексов, поэтому не заостряем внимание.


1.1.3. Класс AppDelegate


AppDelegate просто отображает тот самый массивный ViewController.


import UIKit

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
  var window: UIWindow?

  func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    window = UIWindow(frame: UIScreen.main.bounds)
    window?.backgroundColor = .white
    window?.rootViewController = MyViewController()
    window?.makeKeyAndVisible()
    return true
  }
}

1.1.4. Класс MyViewController


Наконец, на сцену вступает MyViewController. Он уже не такой маленький, поэтому полностью дублировать не буду. Рассмотрим лишь ключевые вещи.


// 1.1.4.1
  override func viewDidLoad() {
    - - - -
    // Настраиваем CallKit.
    let cfg = CXProviderConfiguration()
    cfg.supportedHandleTypes = [.generic]
    provider = CXProvider(configuration: cfg)
    provider?.setDelegate(self, queue: DispatchQueue.main)

    // Настраиваем получение пушей VoIP.
    vps.delegate = self
  }

Тут делаем минимальную настройку для работы с CallKit: разрешаем приём любых типов звонков и задаём основную очередь (main queue) для обработки уведомлений от CallKit. Также начинаем слушать уведомления от соответствующей симуляции пушей.


// 1.1.4.2
  @objc func simulateOutgoingCall(sender: UIButton) {
    guard let id = textField.text else { return }
    vcs.startCall(callId: id)
  }

Тут симулируем звонок из приложения.


 
Это первый вызов startCall()
 

// 1.1.4.3
  @objc func simulateIncomingCall(sender: UIButton) {
    vps.simulate(payload: UUID().uuidString, after: .seconds(3))
  }

Тут запускаем симуляцию входящего звонка через 3 секунды со случайным id.


// 1.1.4.4
  func voipPushSimulationDidReceivePayload(_ payload: String) {
    guard let id = UUID(uuidString: payload) else { return }
    voipPushCallId = payload
    let upd = CXCallUpdate()
    upd.remoteHandle = CXHandle(type: .generic, value: "Wake up, Neo")
    provider?.reportNewIncomingCall(with: id, update: upd) { _ in }
  }

Тут мы с помощью CallKit отображаем плашку входящего вызова:



// 1.1.4.5
  func provider(_: CXProvider, perform action: CXAnswerCallAction) {
    action.fulfill()
    guard let id = voipPushCallId else { return }
    vcs.startCall(callId: id)
  }

Тут мы уже начинаем звонок в ответ на нажатие кнопки «✅».


 
Это второй вызов startCall()
 

1.1.5. Первая проблема подхода


Первая проблема состоит в том, что у нас в двух заранее неизвестных местах происходит вызов одной из самых важных функций — startCall(). В нашем крошечном приложении таких вызовов два, но в настоящем приложении их может быть намного больше: звонок из списка чатов, из самого чата, из истории звонков iPhone и т.д… Нам повезёт, если все эти вызовы будут хотя бы в одном UIViewController, но в жизни обычно эти вызовы оказываются в разных UIViewController'ах.


Таким образом, критичная для приложения функция есть, но находится она чёрт знает где. А потом прибегает заказчик и требует добавить возможность позвонить из совершенно неожиданного места в тридевятом царстве экране.


 
Постулат, версия №1
 
Вызов функции из более чем одного места нарушает принцип Единого Источника Истины (Single Source Of Truth).
 

Я уже слышу гневный стук клавиш на вашей клавиатуре, ведь функции придумали как раз для повторного использования и более чем одного вызова:




Всё так, но лучше обратиться к более фундаментальному определению функции:




Это определение даёт нам подсказку о том, что функции различаются масштабом:


Характеристика функции String.prefix() VideoCallSimulation.startCall()
Известность Известна всем программистам iOS Дай Бог все программисты iOS в команде слышали о ней
Побочные эффекты Не содержит, является чистой функцией Дай Бог хотя бы один программист iOS в команде знает, как её вызвать без ошибок
Очевидность Из названия понятно, что мы просто префикс строки достаём Где-то тут зарыт звонок, но у программистов команды iOS уйдёт много времени, чтобы объяснить друг другу, что является звонком с точки зрения функции
Отраслевой стандарт Любой программист с опытом на другом языке сможет после пары минут чтения описания воспользоваться этой функцией Если вдруг работодатель даст добро выложить вашу реализацию звонка в открытый доступ под GPL, то использовать эту библиотеку будет всё равно лишь ваша команда
Риск Если вдруг что-то работает не так, то корректный пример использования можно найти в Интернете Если вдруг что-то работает не так, то придётся засучить рукава и усердно биться головой о стенку код

Таким образом, некоторые крошечные функции вполне естественно вызывать везде, где это необходимо. А другие — смерти подобно.


1.2. Первое исправление нарушения принципа ЕИИ (SSOT) для startCall()


Одним из самых очевидных способов сделать вызов startCall() ровно в одном месте является введение промежуточного звена в виде замыканий (closures), по одному на каждый случай.


После этих изменений MyViewController выглядит следующим образом:


class MyViewController: UIViewController, VoIPPushSimulationDelegate, CXProviderDelegate {
  - - - -
  private var makeUICall: ((String) -> Void)?
  private var makeVoIPCall: ((String) -> Void)?
  - - - -
  override func viewDidLoad() {
    - - - -
    // Совершаем звонок разными способами:
    // 1. из UI
    // 2. в ответ на VoIP push
    makeUICall = { [weak self] id in self?.vcs.startCall(callId: id) }
    makeVoIPCall = makeUICall
  }
  - - - -
  @objc func simulateOutgoingCall(sender: UIButton) {
    guard let id = textField.text else { return }
    makeUICall?(id)
  }
  - - - -
  func provider(_: CXProvider, perform action: CXAnswerCallAction) {
    action.fulfill()
    guard let id = voipPushCallId else { return }
    makeVoIPCall?(id)
  }
  - - - -
}

Теперь функцию startCall() стало видно лучше: она не похоронена где-то в глубине и в нескольких местах. Однако, подобная реализация через замыкания выглядит отталкивающей и врядли кому-то понравится. В ней не хватает объединения замыканий во что-то цельное, ведь нарушение ЕИИ/SSOT проистекает из дробления единого целого — совершения звонка.
В подобной ситуации часто решают создать отдельный класс для того, чтобы он занимался совершением звонка. Но ведь у нас уже естьVideoCallSimulation, он ведь является тем самым искомым классом? Нет, не является, т.к. он выполняет лишь утилитарную функцию совершения звонка на основе переданных данных, но самих данных у него нет.


 
Постулат, версия №2
 
Вызов функции из более чем одного места с разными значениями параметров в каждом случае нарушает принцип Единого Источника Истины (Single Source Of Truth).
 

Таким образом, функция у нас не просто так вызывается более, чем один раз. Она вызывается в разных случаях.


Уместно вспомнить прибегавшего выше заказчика, хотевшего совершать звонок из нового экрана. Это как раз ситуация добавления нового случая использования функции startCall(). Когда мы вводили замыкания в попытке удовлетворить требованию постулата, мы упустили зависимость функции startCall() от условий.


1.3. Куда мы, собственно, идём?


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


while true {
  if let id = voipCallId ?? textCallId {
    vcs.startCall(id)
  }
}

Заметил следующее:


  • запуск звонка может произойти в любой момент, т.к. мы проверяем условия запуска звонка в главном цикле;
  • запуск звонка может произойти при возникновении одного из двух условий:
    • приняли входящий звонок по VoIP push;
    • совершили исходящий звонок после ввода номера в поле ввода.

Конечно, если вы напишите именно такой код, то на вас косо посмотрят не только лишь все.


 
Пишите в комментариях, почему лично вы бы не пропустили данный кусок кода на проверке/review.
 

Мы пойдём более привычным путём реактивного программирования. Его суть заключается в термине «реакция»:




В псевдокоде вызов startCall() являлся действием, вызванным в ответ на воздействия, описанные в if. Но как записать эти воздействия так, чтобы с этим потом можно было работать? Тут нам поможет библиотека Combine от Apple.


1.4. Меняем замыкания на узлы Combine


После замены замыканий на узлы Combine MyViewController будет выглядеть следующим образом:


class MyViewController: UIViewController, VoIPPushSimulationDelegate, CXProviderDelegate {
  - - (1) - -
  private let makeUICall = PassthroughSubject<String, Never>()
  private let makeVoIPCall = PassthroughSubject<String, Never>()
  - - - -
  override func viewDidLoad() {
    - - (2) - -
    // Совершаем звонок разными способами:
    // 1. из UI
    // 2. в ответ на VoIP push
    Publishers.Merge(makeUICall, makeVoIPCall)
      .sink { [weak self] id in self?.vcs.startCall(callId: id) }
      .store(in: &subscriptions)
  }
  - - (3) - -
  @objc func simulateOutgoingCall(sender: UIButton) {
    guard let id = textField.text else { return }
    makeUICall.send(id)
  }
  - - (4) - -
  func provider(_: CXProvider, perform action: CXAnswerCallAction) {
    action.fulfill()
    guard let id = voipPushCallId else { return }
    makeVoIPCall.send(id)
  }
  - - - -
}

Разберём по пунктам:


  1. Узлами (nodes) называют экземпляры PassthroughSubject. В узлы мы в пунктах 3 и 4 с помощью функции send() отправляем значения/данные.
  2. Реактивной цепочкой (reactive chain) назызывают последовательность реактивных операторов. В данном примере срабатывание любого из узлов makeUICall и makeVoIPCall в ответ на вызов send() приводит к исполнению кода в операторе sink(). Цепочка существует столь долго, сколько существует подписка (subscription) на неё, подписку мы сохраняем оператором store().
  3. При нажатии на кнопку «Начать звонок» получаем значение из UITextField и передаём в узел makeUICall.
  4. При нажатии на кнопку «✅» в CallKit передаём значение в узел makeVoIPCall.

 
Реактивная цепочка объединяет условия и функцию в единое целое.
 

Мы стали чуть ближе к цели: у нас есть теперь некое объединение условий и функций. Однако, мы всё ещё не видим в этой цепочке работу с данными. Исправим это.


1.5. Отделяем данные номера звонка от факта нажатия на кнопку


Основным смыслом создания реактивных цепочек является работа с данными. Хорошая цепочка показывает полный путь от появления данных до их обработки. В нашем случае звонок начинается либо когда мы нажимаем кнопку «Начать звонок», либо когда мы нажимаем на кнопку «✅» в CallKit. А номер звонка (данные) мы получаем до нажатия на любую из этих кнопок.


Таким образом, у нас есть четыре входящих источника данных для запуска звонка:


  • номер звонка из поля ввода;
  • нажатие на кнопку «Начать звонок»;
  • номер звонка из VoIP push;
  • нажатие на кнопку «✅» в CallKit.

Все эти данные должны в конечном итоге оказаться в реактивной цепочке. Будем это делать постепенно. Начнём с первых двух источников.


После исправления MyViewController будет выглядеть так:


class MyViewController: UIViewController, VoIPPushSimulationDelegate, CXProviderDelegate {
  - - (1) - -
  private let makeUICall = PassthroughSubject<Void, Never>()
  - - (2) - -
  private let textCallId = PassthroughSubject<String, Never>()
  - - - -
  override func viewDidLoad() {
    - - (3) - -
    textField.addTarget(self, action: #selector(didChangeTextField), for: .editingChanged)
    - - (4) - -
    // Совершаем звонок разными способами:
    // 1. из UI
    // 2. в ответ на VoIP push
    Publishers.Merge(
      Publishers.CombineLatest(
        textCallId,
        makeUICall
      )
        .map { $0.0 },
      makeVoIPCall
    )
      .sink { [weak self] id in self?.vcs.startCall(callId: id) }
      .store(in: &subscriptions)
  }
  - - (5) - -
  @objc func didChangeTextField(_: UITextField) {
    guard let id = textField.text else { return }
    textCallId.send(id)
  }
  - - (6) - -
  @objc func simulateOutgoingCall(_: UIButton) {
    makeUICall.send(())
  }
  - - - -
}

Разберём по пунктам:


  1. Узел makeUICall теперь не содержит данных, поэтому тип Void
  2. Появился узел textCallId, данные теперь передаёт этот узел из UITextField
  3. Для получения данных из UITextField мы подписываемся на событие .editingChanged
  4. Первый аргумент в Publishers.Merge() усложнился: теперь тут целый оператор Publishers.CombineLatest(), который срабатывает при одновременном наличии данных как в textCallId, так и makeUICall.
  5. На каждое событие .editingChanged мы отправляем значение UITextField в узел textCallId
  6. На нажатие кнопки «Начать звонок» мы отправляем Void в узел makeUICall

Теперь первый аргумент Publishers.Merge() нам во всех деталях описывает, как на самом деле происходит звонок:


  • появляется номер звонка в UITextField
  • пользователь нажимает кнопку
  • осуществляем звонок в sink()

Раньше мы тянулись к данным UITextField сразу же в обработчике нажатия на кнопку. Теперь же мы разделили данные из UITextField и нажатие на кнопку на два разных канала — узлы textCallId и makeUICall.


Таким образом, узлы позволили нам реализовать Принцип Единственной Ответственности (Single Responsibility Principle):


  • обработчик UITextField занимается строго данными из UITextField
  • обработчик нажатия кнопки занимается строго оповещением о факте нажатия
  • решение же о том, что нужно делать при наличии данных в UITextField и нажатии на кнопке, принимает цепочка, а не обработчик в тридевятом классе

 
Сейчас нужно остановиться и осознать весь масштаб произошедших изменений:
Мы переместили контроль от утилитарной незаметной функции в цепочку.
 

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


1.6. Отделяем данные номера звонка из VoIP push от факта нажатия на кнопку CallKit


Теперь пришла пора поменять второй аргумент Publishers.Merge(). После исправления MyViewController будет выглядеть так:


class MyViewController: UIViewController, VoIPPushSimulationDelegate, CXProviderDelegate {
  - - (1) - -
  private let makeVoIPCall = PassthroughSubject<Void, Never>()
  - - (2) - -
  private let voipPushCallId = PassthroughSubject<String, Never>()
  - - - -
  override func viewDidLoad() {
    - - (3) - -
    // Совершаем звонок разными способами:
    // 1. из UI
    // 2. в ответ на VoIP push
    Publishers.Merge(
      Publishers.CombineLatest(
        textCallId,
        makeUICall
      )
        .map { $0.0 },
      Publishers.CombineLatest(
        voipPushCallId,
        makeVoIPCall
      )
        .map { $0.0 }
    )
      .sink { [weak self] id in self?.vcs.startCall(callId: id) }
      .store(in: &subscriptions)
  }
  - - (4) - -
  func voipPushSimulationDidReceivePayload(_ payload: String) {
    guard let id = UUID(uuidString: payload) else { return }
    voipPushCallId.send(payload)
    - - - -
  }
  - - (5) - -
  func provider(_: CXProvider, perform action: CXAnswerCallAction) {
    action.fulfill()
    makeVoIPCall.send(())
  }
  - - - -
}

Разберём по пунктам:


  1. Узел makeVoIPCall теперь не содержит данных, поэтому тип Void
  2. Переменная voipPushCallId стала узлом
  3. Второй аргумент в Publishers.Merge() стал оператором Publishers.CombineLatest(), срабатывающим при одновременном наличии данных в voipPushCallId и makeVoIPCall
  4. На каждое получение VoIP push мы отправляем его содержимое в узел voipPushCallId
  5. На нажатие кнопки «✅» в CallKit мы отправляем Void в узел makeVoIPCall

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


1.7. Корректировка реактивной цепочки


Мы уже несколько раз использовали оператор map(), но так и не разобрали магическую запись map { $0.0 }. Данная запись позволяет нам выдернуть первую часть — id звонка — при срабатывании CombineLatest, т.к. CombineLatest отправляет нам кортеж из всех элементов с их текущими значениями. Очевидно, что нам нет смысла получать Void, поэтому мы эту часть отсекаем.


Кроме того, текущая версия цепочки срабатывает не только на нажатия кнопок, но и при смене id. Например, если мы будем вводить id звонка после запуска звонка по кнопке «Начать звонок», то цепочка будет срабатывать на каждое изменение символа:




Это стандартное поведение CombineLatest: когда меняется любой из аргументов, то мы получаем комбинацию их последних значений. Однако, в нашем случае мы хотим получать сигналы от CombineLatest лишь на нажите кнопки, а не на изменение id. Поэтому мы можем каждый аргумент сопроводить временем изменения. Тогда пропускать сигналы нам нужно лишь тогда, когда время нажатия на кнопку превышает время прихода изменения id, т.е. сначала нужно изменить id, а лишь затем нажать на кнопку.


После исправления MyViewController будет выглядеть так:


class MyViewController: UIViewController, VoIPPushSimulationDelegate, CXProviderDelegate {
  - - - -
  override func viewDidLoad() {
    - - - -
    // Совершаем звонок разными способами:
    // 1. из UI
    // 2. в ответ на VoIP push
    Publishers.Merge(
      Publishers.CombineLatest(
        textCallId.map { ($0, Date()) },
        makeUICall.map { ($0, Date()) }
      )
        .filter { $0.1.1 > $0.0.1 }
        .map { $0.0.0 },
      Publishers.CombineLatest(
        voipPushCallId,
        makeVoIPCall
      )
        .map { $0.0 }
    )
      .sink { [weak self] id in self?.vcs.startCall(callId: id) }
      .store(in: &subscriptions)
  }
  - - - -
}

Итак, мы каждому аргументу добавили дату, после чего в фильтре отсекли те случаи, когда id изменяется до нажатия на кнопку. Поэтому теперь поведение стало корректным:




Ту же технику с датой нужно применить и к случаю начала звонка при принятии VoIP. После исправления MyViewController будет выглядеть так:


class MyViewController: UIViewController, VoIPPushSimulationDelegate, CXProviderDelegate {
  - - - -
  override func viewDidLoad() {
    - - - -
    // Совершаем звонок разными способами:
    // 1. из UI
    // 2. в ответ на VoIP push
    Publishers.Merge(
      Publishers.CombineLatest(
        textCallId.map { ($0, Date()) },
        makeUICall.map { ($0, Date()) }
      )
        .filter { $0.1.1 > $0.0.1 }
        .map { $0.0.0 },
      Publishers.CombineLatest(
        voipPushCallId.map { ($0, Date()) },
        makeVoIPCall.map { ($0, Date()) }
      )
        .filter { $0.1.1 > $0.0.1 }
        .map { $0.0.0 }
    )
      .sink { [weak self] id in self?.vcs.startCall(callId: id) }
      .store(in: &subscriptions)
  }
  - - - -
}

1.8. Неудобства выверенной реактивной цепочки


Где-то на этом месте вы уже должны были начать сомневаться в том, что нужно внедрять реактивное программирование, ибо всего лишь одна реактивная цепочка начинает приобретать монструозные очертания. А ведь этих цепочек в приложении будут сотни.


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


Впрочем, с подобными далеко не самыми сложными реактивными цепочками есть ещё один нюанс. Не только человеку сложно с ними работать, но даже и компилятору. Например, сборка первой версии правок пункта 1.7 на Xcode 14.0.1 (MacBook Pro 2019 16" на 6-ядерном Intel Core i7 2,6 ГГц) занимает 1-2 секунды. Сборка же второй версии правок уже занимает 2-3 минуты. И это уже не спишешь на субъективный фактор нравится / не нравится.


Часть 2. Суть описываемого архитектурного шаблона




2.1. Мрак


Теперь самое время перейти к определению термина «мрак», т.к. именно он нам не нравится в описанной выше монструозной цепочке.


 
Мрак — это скопление условий, чаще всего неочевидных и в неожиданных местах.
 

Термин «мрак» я намеренно использую вместо «сложности», подробно и хорошо разобранной в статье «Закон сохранения сложности», потому что сложность относительна, а мрак не зависит от опытности программиста. Мрак просто есть, потому что заказчик хочет видеть своё приложение с корректной вёрсткой и адекватным поведением при всех состояниях сети, всех поворотах устройства, любом шрифте и т.д. Все эти требования сверху мы проигнорировать и видоизменить не можем, мы можем их лишь принять и попытаться разместить в коде так, чтобы они нас не убили.


Мрак никак не связан с реактивным программированием, его много и в обычных императивных примерах кода на StackOverflow, и в популярных библиотеках, например, JSON-java для работы с JSON в Java:


public JSONArray(JSONTokener x) throws JSONException {
        this();
        if (x.nextClean() != '[') {
            throw x.syntaxError("A JSONArray text must start with '['");
        }

        char nextChar = x.nextClean();
        if (nextChar == 0) {
            // array is unclosed. No ']' found, instead EOF
            throw x.syntaxError("Expected a ',' or ']'");
        }
        if (nextChar != ']') {
            x.back();
            for (;;) {
                if (x.nextClean() == ',') {
                    x.back();
                    this.myArrayList.add(JSONObject.NULL);
                } else {
                    x.back();
                    this.myArrayList.add(x.nextValue());
                }
                switch (x.nextClean()) {
                case 0:
                    // array is unclosed. No ']' found, instead EOF
                    throw x.syntaxError("Expected a ',' or ']'");
                case ',':
                    nextChar = x.nextClean();
                    if (nextChar == 0) {
                        // array is unclosed. No ']' found, instead EOF
                        throw x.syntaxError("Expected a ',' or ']'");
                    }
                    if (nextChar == ']') {
                        return;
                    }
                    x.back();
                    break;
                case ']':
                    return;
                default:
                    throw x.syntaxError("Expected a ',' or ']'");
                }
            }
        }
    }

Беглого взгляда на этот пример хватает, чтобы мысленно поблагодарить отважных первопроходцев, взявшихся обуздать этот мрак, чтобы наш код при соприкосновении с JSON был приятным и понятным.


Однако, подобный общепризнанный мрак особой сложности обычно не представляет, т.к. мы его не придумывали, мы лишь следовали требованиям общепризнанной системы (например, формата JSON). В интернете часто можно найти достаточно деталей по подобным вещам, чтобы в них разобраться. Этот мрак принадлежит индустрии IT, с ним мы привыкли худо-бедно жить.


Совсем другим является мрак частный. Тут уже интернет почти всегда бессилен дать подсказку по интересующему нас вопросу, т.к. этот вид мрака касается лишь бизнес-процессов компании, наложенных на ограничения системы, например, iOS. Именно эта часть кода обычно съедает всё рабочее время программиста. Именно с этой частью мы в статье и работаем.


2.2. Мрак в моделях


Итак, суть архитектурного шаблона «Мрак в Моделях» в том, что мы мрак/монструозность по максимуму переносим в так называемую модель. В нашем примере модель выглядит так:


struct Model {
  var isCallButtonPressed = false
  var isCallKitOKButtonPressed = false
  var textCallId: String?
  var voipCallId: String?
}

extension Model {
  // Следует начать звонок, если:
  // 1. нажали на кнопку «Начать звонок» при наличии номера звонка
  // 2. нажали на кнопку «✅» в CallKit при наличии номера звонка из пуша
  var shouldMakeCall: String? {
    if
      isCallButtonPressed,
      let id = textCallId
    {
      return id
    }

    if
      isCallKitOKButtonPressed,
      let id = voipCallId
    {
      return id
    }

    return nil
  }
}

Модель:


  • хранит все необходимые данные в себе;
  • является местом принятия почти всех решений;
  • не меняет сама себя, модель меняется лишь Контроллером.

Как следствие:


  • более не нужны неочевидные операторы filter, map для того, чтобы взять id при нажатии на кнопку;
  • не какой-то метод где-то глубоко в коде решает, что делать при наличии id и нажатии на кнопку, а определённая модель.

2.3. Упрощённая реактивная цепочка


Теперь полюбуемся, как преобразилась реактивная цепочка после исключения из неё мрака:


class MyViewController: UIViewController, VoIPPushSimulationDelegate, CXProviderDelegate {
  - - - -
  override func viewDidLoad() {
    - - - -
    // Совершаем звонок.
    ctrl.m
      .compactMap { $0.shouldMakeCall }
      .sink { [weak self] id in self?.vcs.startCall(callId: id) }
      .store(in: &subscriptions)
    - - - -
  }
- - - -
}

Цепочка стала прямолинейной, читается одинаково легко как человеком, так и компилятором (больше нет странных сборок по 2 минуты). Весь мрак ушёл в модель, а тут стало чисто и уютно.


2.4. Правки


В пункте 1.7 мы уже правили неверное срабатывание цепочки при вводе номера в поле ввода. В нашей новой версии с мраком в модели такой проблемы нет. Но с самой первой версии осталась другая проблема: можно совершить звонок на пустой номер, когда поле ввода пустое:




Очевидно, что нужно запретить звонок при пустом номере. Теперь это делается очень легко благодаря хранению мрака в модели:


extension Model {
  // Следует начать звонок, если:
  // 1. нажали на кнопку «Начать звонок» при наличии номера звонка
  // 2. нажали на кнопку «✅» в CallKit при наличии номера звонка из пуша
  var shouldMakeCall: String? {
    if
      isCallButtonPressed,
      let id = textCallId,
      - - (1) - -
      !id.isEmpty
    {
      return id
    }

    if
      isCallKitOKButtonPressed,
      let id = voipCallId,
      - - (2) - -
      !id.isEmpty
    {
      return id
    }

    return nil
  }
}

Добавили проверку !id.isEmpty, и всё. Теперь поведение корректное:




Разве модель не прекрасна?


2.5. Передача данных в модель


Пришло время изучить Контроллер, который занимается передачей данных из реактивных узлов в модель. Выглядит он в нашем примере следующим образом:


class Controller: MPAK.Controller<Model> {
  init() {
    super.init(Model())
  }
}

extension Controller {
  func setupCall(_ myVC: MyViewController) {
    pipe(
      myVC.makeUICall.eraseToAnyPublisher(),
      { $0.isCallButtonPressed = true },
      { $0.isCallButtonPressed = false }
    )

    pipe(
      myVC.makeVoIPCall.eraseToAnyPublisher(),
      { $0.isCallKitOKButtonPressed = true },
      { $0.isCallKitOKButtonPressed = false }
    )

    pipeValue(
      myVC.textCallId.eraseToAnyPublisher(),
      { $0.textCallId = $1 }
    )

    pipeValue(
      myVC.voipPushCallId.eraseToAnyPublisher(),
      { $0.voipCallId = $1 }
    )
  }
}

Контроллер:


  • хранит экземпляр модели
  • при изменении значений модели оповещает через узел m всех заинтересованных об изменениях
  • не содержит логики (if), просто пробрасывает значения из реактивного мира узлов в императивный мир модели

Функции pipe() и pipeValue() внутри содержат реактивные цепочки, которые сначала исполняют первое замыкание, а затем второе. Подобная двухтактность даёт возможность легко отражать в модели факт краткосрочных событий вроде нажатия на кнопку или момент получения ответа от сервера.


2.6. Взаимодействие сущностей MyViewController, Контроллер, Модель


Кто кем владеет:


  • MyViewController содержит публичный экземпляр Контроллера
  • Контроллер содержит приватный экземпляр Модели
  • Модель содержит данные в виде простых типов (value type) Bool, Int, String или какие-нибудь DTO

Данные движутся в одном направлении (с зацикливанием в MyViewController):


  1. MyViewController отправляет данные от сущностей в Контроллер
    • например, нажатие кнопки или введённый в поле ввода номер
  2. Контроллер
    • записывает данные в Модель
    • оповещает подписантов об изменениях Модели
  3. Модель предоставляет интерпретацию (computed variables) хранящихся данных для принятия решений
  4. MyViewController исполняет принятые Моделью решения
    • например, вызывает у сущности VideoCallSimulation метод startCall()

Кто какую роль исполняет:


  • MyViewController — исполнитель решений
  • Controller — диспетчер
  • Model — суд (принятие решений)

Часть 3. Архитектурный шаблон ММ в жизни


3.1. Приложение конвертации валют на MM


Первоначально данная статья задумывалась как сравнение двух реализаций (VIPER и MM) одного приложения. В ходе сравнения стало ясно, что шаблон MM надо раскрыть как можно полнее. Надеюсь, мне это удалось хотя бы на 30%.


Эталоном реализации на VIPER я взял этот конвертер валют и немного подправил его до рабочего состояния, после чего создал версию на ММ.


Взглянем на основные возможности конвертера валют (версия MM):




Из кода приведу лишь вычисление итоговой суммы конвертации в модели:


  // Вычисляем значение поля назначения, если:
  // 1. изменили сумму для конвертации
  // 2. загрузили курсы валют
  // 3. изменили валюту-источник
  // 4. изменили валюту-назначение
  public var shouldResetAmountDst: String? {
    guard
      amount.isRecent ||
      rates.isRecent ||
      src.isoCode.isRecent ||
      dst.isoCode.isRecent
    else {
      return nil
    }

    guard
      let money = Double(amount.value),
      let conversion = convert(money)
    else {
      return "0.0"
    }

    let result = String(conversion)
    let parts = result.components(separatedBy: ".")
    guard
      let integer = parts.first,
      let fraction = parts.last
    else {
      return nil
    }
    return integer + "." + fraction.prefix(2)
  }

И условия, и вычисления в одном месте, весь мрак как на ладони.


Также замечу, что из интересного в приложении присутствуют: модули, несколько экземпляров UIWindow, встраивание SwiftUI в UIKit, xcodegen.


3.2 Итоги


Итак, самые важные моменты:


  • Вызов функции из более чем одного места с разными значениями параметров в каждом случае нарушает принцип Единого Источника Истины
  • Мрак — это скопление условий, чаще всего неочевидных и в неожиданных местах
  • Архитектурный шаблон «Мрак в Моделях» не является инвестиционной рекомендацией Святым Граалем, но позволяет отделить мух от котлет и есть котлеты вилкой

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


  1. ws233
    00.00.0000 00:00
    +2

    А закончить надо было таким утверждением:

    Мрак не в клозетах моделях, мрак – в головах

    А теперь серьезно. Где сравнение-то? Какова цель архитектуры? Тема мрака не раскрыта.


    1. kornerr Автор
      00.00.0000 00:00

      1. Сравнение чего с чем и по каким параметрам?

      2. Цель архитектуры - держать мрак в моделях, а не пачкать им весь остальной код.

      3. Чего не хватило для раскрытия мрака?


      1. ws233
        00.00.0000 00:00

        Первоначально данная статья задумывалась как сравнение двух реализаций (VIPER и MM) одного приложения.

        Не хватило понимания, чем ММ лучше, а чем хуже.


        1. kornerr Автор
          00.00.0000 00:00

          Несколько раз пытался это подчеркнуть в статье, возможно, это не очень удалось, попробую ещё раз.

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

          В ММ место принятия решений одно - это модель. Причём принятие решений написано в самом обычном императивном стиле, никакой асинхронщины, даже несмотря на то, что приложение реактивное. Когда место принятия решений одно, то всегда должно быть ясно, куда и что писать. Т.е. в идеале не должно возникать вопросов "Где находится X", "Куда написать X", "Где поправить X".