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

MVVM-(X), VIP, VIPER — все эти так называемые архитектуры имеют две общие черты: все они ведут свое происхождение от Чистой архитектуры Роберта С. Мартина. Я не видел ни одной из допустимых архитектур в iOS. Они не обладают абсолютной независимостью от пользовательского интерфейса (Мартин: Пользовательский интерфейс должен быть устройством ввода-вывода) и не позволяют вам откладывать решения на более поздний момент времени, возможно, бесконечно.

Теперь, в iOS, долгое время стоял вопрос «Куда поместить ViewController» — заставить архитектуру отвечать на вопросы, с которыми ее никогда не следует беспокоить. А со SwiftUI этот вопрос переместился на «Создание (MVVM|VIPER|VIP) в SwiftUI» — связывание пользовательского интерфейса с самого начала с любым другим кодом — и нарушение всего, за что выступает архитектура.

Общим для всех этих не-архитектур является тот факт, что все они требуют реализации разных объектов:

  • Координатор
  • Маршрутизатор
  • Ведущий
  • ViewModel
  • DI-фреймворки

Все эти объекты просто существуют как потребность в недопустимой архитектуре — они вообще не вносят вклад в домен приложения — и домен является причиной, по которой приложение было введено в эксплуатацию в первую очередь. Ни один клиент не заказывает приложение, требуя, чтобы оно содержало определенное количество маршрутизаторов и координаторов. Следовательно, все эти объекты должны считаться случайной сложностью или шаблонными кодами.

Еще один взгляд на стандартные коды и случайные сложности:
На фундаментальном уровне все приложения очень похожи:

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

Назовем это «Внутренний дизайн».

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

Теперь, когда мы увидели, что большинство архитектур недействительны и полны шаблонного кода, позвольте мне представить Кипу — действительную реализацию Чистой архитектуры Роберта С. Мартина, в которой нет случайных кодов сложности. совсем.

Кипу достигает этого, везде придерживаясь простоты.

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

  • Однонаправленный поток данных: легко понять и обосновать. Если данные могут передаваться только в одном направлении, это значительно упрощает архитектуру.
  • Неизменяемые типы данных: как мы вскоре увидим, я предлагаю использовать только неизменяемые типы данных. Можно много говорить о том, почему это выгодно, но я хочу быть кратким: если что-то нельзя изменить намеренно, это также не может измениться случайно — устраняя наиболее вероятные ошибки, которые приведут к краху системы.

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

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

Классы не выполняют свои основные обещания. Кроме того, они не могут обеспечить поток, как ожидается от модуля — для реализации этого требуется магия шаблонов. Я присоединяюсь к Илье Суздальницкому и утверждаю, что, безусловно, большинство шаблонов необходимы только для устранения недостатков ООП (и классов):

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

Горькая правда заключается в том, что ООП не справляется с единственной задачей, для решения которой оно предназначено. На бумаге это выглядит хорошо — у нас есть четкие иерархии животных, собак, людей и т. д. Однако все становится не так, как только сложность приложения начинает расти.

Вместо того, чтобы уменьшить сложность, он поощряет неразборчивоесовместное использование изменяемого состоянияи вводит дополнительную сложность благодаря многочисленным шаблонам проектирования. ООП делает обычные методы разработки, такие как рефакторинг и тестирование, излишне сложными».

И из-за принципа Человека-паука мы не будем полагаться на классы в центре системы, а скорее на метод, который малоизвестен в ООП, но предшествует ему на десятилетия: частичное применение. Что ж, начнем с этого.

Частичное применение

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

createAdder принимает значение Int и возвращает другую функцию. Эта возвращенная функция обращается к значению и изменяет его, добавляя вновь предоставленное целое число и возвращая новое значение — частичное применение — это способ управления побочными эффектами и состоянием (примечание: поэтому оно не входит в ваш набор инструментов функционального программирования).

Комбинируя частично применяемые функции с кортежами функций, мы можем сделать следующее:

  • Stack<T> определяется как кортеж функции push и pop.
  • createStack создает массив, к которому будут обращаться и изменять его функции push и pop. Возвращается Stack<T>-кортеж
  • Как мы видим, начиная со строки 8, использование этих настраиваемых объектов не отличается от использования экземпляра класса или структуры.

В Khipu мы используем следующий настраиваемый объект для хранения состояния приложения (динамически типизированный<S>) и изменяющий его с помощью динамического типа <C>:

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

Реализация дискового хранилища на платформах Apple, <S> становится AppState, <C> AppState.Change — что будет объяснено позже:

Я упоминал ранее, что Кипу может быть практически неизменен. Переназначения переменных state и callbacks в строках 10, 11 и 12 — единственные такие присвоения во всем проекте — все остальные коды могут и должны быть неизменяемыми.

Неизменяемые типы данных и AppState

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

Следующие модели типов данных представляют собой источник света для приложения интеллектуального освещения (например, Phillips Hue). Как мы видим в строках с 19 по 30, все свойства имеют тип let — следовательно, мы знаем, что эта структура неизменяема. Чтобы отразить изменения, такие как включение или выключение, изменение цветовой температуры или оттенка, насыщенности и яркости, нам нужно создать новые версии одного и того же объекта.

Здесь мы достигаем этого с помощью метода alter (строка 42), который принимает значение Change, как определено в перечислении Change в строке 2.
Change определяет следующие значения:

  • .renaming(.it(to:<newname>))
  • .turning(.it(.on)) и .turning(.it(.off))
  • .adding(.mode(<lightmode>))
  • .toggling(.display(to:<interface>))
  • .setting(.hue(to:<value>))
  • .setting(.saturation(to:<value>))
  • .setting(.brightness(to:<value>))
  • .setting(.temperature(to:<value>))

Эти значения описывают желаемое поведение — перечисление Change кодирует поведение.

Затем поведение декодируется путем сопоставления с шаблоном в методе alter (строка 48), где все случаи следуют одному и тому же шаблону:

  • слева от двоеточия каждого оператора case (lhs : rhs) намерение извлекается путем декодирования значения с помощью сопоставления с образцом. Справа от этого двоеточия определено соответствующее действие
    lhs:case .turning(.it(.on))
    rhs:return .init(id,name,.on,b,s,h,ct,display,modes,selectedMode)
    Здесь мы видим аксиому: компилятор научили, что означает .turning(.it(.on)) — предыдущее значение onOrOff будет заменено на .on, а все остальные значения будут использоваться повторно. Это известно как «рекурсия конструктора».

Мы можем объединить эти восемь аксиом в более сложное поведение, как показано ниже:

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

пусть свет будет: Свет с идентификатором и именем «01»,
изменить на
* установить яркость на 0,5
* установить оттенок на 0,5
* установить насыщенность на 0,5
* установка температуры на 200 мирек
* включение

Объекты модели хранятся в AppState — неизменяемой структуре, которая работает так же, как и типы моделей — декодирование поведения в перечислении Change и реализация его посредством сопоставления с образцом в методе alter:

AppState’s Change enum позволяет использовать следующие DSL:

Сам AppState тоже неизменяем, что означает, что мы должны хранить его в ссылочном типе. Я показывал вам Store ранее. Это кортеж функций, и это должны быть функции, так как сам кортеж является типом значения, а функции — необходимым ссылочным типом.

Store используется следующим образом:

Теперь, когда мы увидели, как обращаться с нашими моделями неизменяемым образом, давайте двигаться дальше и посмотрим на UseCases — основной строительный блок Чистой Архитектуры Мартина.

Варианты использования и особенности

UseCases Хипу вдохновлены чистой архитектурой Мартина UseCases, которая следует следующей настройке:

  • UseCase имеет тип Request, который используется для кодирования запросов для этого единственного UseCase
  • входящий запрос будет проанализирован и переведен в вызовы объекта UseCase’s Interactor, который будет создан со всеми необходимыми ему зависимостями. Это включает в себя такие вещи, как работа с сетью и дисками.
  • как только Interactor будет выполнено, он отправит отчет UseCase, который будет использовать вновь собранные данные для создания объекта Response Type.
  • Request и Response уникальны для каждого UseCase, что обеспечивает разделение задач. Это также делает количество значений очень небольшим: обычно вариант использования выполняет одно действие — и поэтому обычно имеет один запрос и один вариант успешного ответа и — при необходимости — один вариант ответа с ошибкой. Это не высечено на камне, и я бы сказал, что может иметь смысл реализовать дополнительные действия в одном варианте использования, то есть: один вариант использования обрабатывает как вход в систему, так и выход из системы, что приводит к 2 случаям запроса.

В Swift мы выражаем это следующим образом:

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

Это PAT — «Протокол со связанными типами», и мы реализуем его следующим образом:

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

В следующем коде показаны примеры запросов и ответов диммера:

Теперь давайте объединим несколько вариантов использования в функцию освещения:

Как мы видим, функция — это просто частично примененная функция, которая принимает любую зависимость (здесь: класс стека освещения для беспроводного управления освещением и объект хранилища) и Output (он же обратный вызов) во время создания и возвращает функцию, которая является Input — функцией, которая принимает значение Message (мы еще вернемся к этому моменту) в качестве параметра и ничего не возвращает.

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

Возвращаемая функция просто соответствует шаблону. Если Message предназначен для функции освещения и перенаправляет его, если оно истинно, в локально определенную функцию execute(command:), какой шаблон соответствует каждому возможному сообщению и соответственно вызывает варианты использования.

Таким образом, в то время как UseCases реализуют логику приложения и взаимодействуют со своими собственными типами Request и Response — их специфическими функциями DSLs — прослушивают сообщения и при необходимости переводят их для своих вариантов использования. и UseCase Responses также переводятся в сообщения — Message является DSL для общесистемного межфункционального взаимодействия.

Сообщения

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

Этот Message кодирует значения для трех функций:

  • осветительные приборы
  • панель приборов
  • Ведение журнала

Значения для функции освещения закодированы в Message._Lighting (строка 30), для функции приборной панели в Message._Dashboad (строка 41) и для регистрации, что неудивительно, Message._Logging (строка 48).

Хотя это выглядит немного более впечатляющим, это все еще та же самая идея, которую мы видели в Change-DSL нашего типа данных Light и типов Request и Response в нашем UseCases: вложенные аннотированные перечисления кодируют поведение и, более того, выражают намерение.

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

Некоторые примеры значений сообщения:

Приложение

Теперь нам нужно собрать все функции в AppDomain — сокращенно: App.

AppDomain — это просто список всех функций, и его единственная задача — получать сообщения и пересылать их каждой функции. Вот код:

Так же, как функции, мы используем частичное применение.

createAppDomain принимает — рядом с любыми зависимостями — output. Он создает все функции, вызывая их функции создания с необходимыми зависимостями, и возвращает функцию ввода — объект AppDomain. Тело этой возвращаемой функции имеет всего одну строку, в которой она перебирает все функции и вызывает их с полученным Message.

Таким образом, вызывая createAppDomain, мы создадим полностью функционирующую систему, в которой у нас есть только одна функция, которая принимает одно значение Message за раз — минимальную площадь поверхности. Но как мы это используем? Что ж, посмотрим.

Подключение пользовательского интерфейса

В этом примере мы подключим AppDomain к интерфейсу SwiftUI.
Для этого мы создадим класс ViewState, который будет служить наблюдаемым объектом.

Он принимает store и подписывается на его уведомление (строка 9). Следовательно, каждое изменение в store приводит к вызову process(appstate), который, в свою очередь, обновит все переменные.

Теперь у нас есть все для сборки приложения:

Мы создаем хранилище, которое будет передаваться как зависимость для состояния просмотра и во время appDomain создания. createAppDomain также принимает массив receivers, который должен быть или иметь функцию обработки сообщений. Мы используем метод viewState handle(msg:) для его подключения. Он будет проинформирован о каждом сообщении, проходящем через систему. Его можно использовать для обработки сообщений, которые не изменяют хранилище, но в моем примере он не используется, так как этот метод пуст.

Последний параметр — roothandler. Он определяется как функция-обертка roothandler. Это замыкает круг: roothandler становится отправной точкой, но также и результатом, который используется для функций.

ContentView может выглядеть так, будто viewState доступен как объект среды.

Теперь LightsCell — это гораздо больший код. Я не выливаю это на вас здесь, но вы найдете в нем следующий метод:

Он вызывает roothandler с сообщением об изменении определенных значений.
Его можно использовать следующим образом:

Различные значения могут быть уменьшены с помощью

и по

Для полной реализации пользовательского интерфейса посетите мой репозиторий.

А как насчет UIKit?

Если вы хотите использовать Khipu с UIKit, вы можете настроить его через UINotifications. Как только произойдет изменение, отправьте уведомление. Добавьте текущее состояние в качестве объекта. Реализуйте контроллер базового представления, который знает, как извлечь состояние из уведомления и вызвать метод, который обрабатывает новое состояние и заполняет пользовательский интерфейс. При необходимости перезапишите его в подклассах.

Теперь, когда мы собрали полное приложение, давайте проверим его структуру.

Как мы видим, он довольно близок к внутреннему дизайну, который я предложил ранее как минимально возможный дизайн для приложений, ориентированных на пользователя.

Это одна и та же структура. Существует только ярлык от выходных данных функций к appDomain (настройка roothandler), позволяющий осуществлять связь между функциями без необходимости маршрутизировать их через состояние и пользовательский интерфейс.

Khipu действительно не содержит шаблонного кода.

Что еще сказать?

Ну, довольно много. Я мог бы говорить о следующем:

Некоторые из этих вещей я уже описывал в предыдущих статьях, другие пока можно найти только в моих репозиториях — но они будут освещены в моей книге Декларативная революция.

Want to Connect?
I will discuss this and other aspects of declarative coding in my talk “Declarative Axiomatic and Provable Correct Systems in Swift” at the “Declarative Amsterdam” conference at the CWI on Nov 8th, 2022. Please join!