Я уже писал о концепции “контейнеров состояний” в 2017 году. Шесть лет спустя мне все еще нравится строить на их основе большинство своих приложений, используя эту концепцию для двух главных слоев внутри моих приложений: Слой представления: модель представления — это «контейнер состояния представления» (view state container), моделирующий состояние представления и бизнес-логику. Доменный слой: обычно агрегирует корневые модели (или репозитории/сервисы данных), представляющие бизнес-правила и сохраняющие целостность и согласованность в рамках определенного ограниченного контекста (или сущности) в приложении. Однако с 2017 года многое изменилось. В 2019 году Apple представила миру SwiftUI. И два года спустя появился async/await. Хоть мы и склонны думать, что хорошие архитектуры не должны зависеть от специфик фреймворков, хорошие архитектуры все-таки являются лояльными гражданами тех же самых фреймворков и общей экосистемы. Так… как же SwiftUI и async/await изменили концепцию контейнеров состояний за последние шесть лет? Давайте посмотрим. Но сначала давайте поговорим о некоторых недавних дебатах в iOS-сообществе касательно MVVM и того, подходит ли он для SwiftUI. Основной тезис заключается в том, что «View уже и так является моделью представления», поэтому MVVM не нужен. Я не согласен с этим тезисом. Хотя и верно то, что SwiftUI уже имеет встроенные примитивы (в виде оберток свойств), призванные упростить большую часть связующего кода для наблюдения за состоянием и повторного рендеринга, View — это просто декларативное описание того, как выглядит слой представления. Модель представления — это гораздо больше. Это то место, где обитает бизнес-логика, связанная с представлением. Но для начала давайте ответим на следующий вопрос. Зачем нам вообще нужно переносить куда-либо код из слоя представления? Не вступая в (иногда очень субъективные) дебаты об ответственности и о том, как следует разделять программное обеспечение, кое-что и так уже предельно ясно: если мы не перенесем код из слоя представления, то модульное тестирование будет очень сложным. Но куда мы должны переместить этот код? Мой ответ на этот вопрос, как и в большинстве случаев, – «это зависит от конкретной ситуации». Иногда такая логика может быть внутри нашего доменного контейнера состояния – ее наблюдают различные представления, которым нужна одна и та же информация. Иногда это просто какая-то бизнес-логика для представления. В этих случаях я предпочитаю помещать эту логику в модель представления (а именно в контейнер состояния представления). В других случаях речь может идти об обработке различной информации из разных источников данных и ее проверке для создания состояния представления. Для всех этих сценариев модель представления является вполне разумным решением. Это решение целесообразно, потому что модель представления объединяет всю необходимую бизнес-логику, которая важна только для этого представления. Перемещение этой специфичной для представления бизнес-логики в доменную модель, с большой долей вероятности, пойдет в ущерб ее целостности и, в конечном счете, выльется в неправильную абстракцию. В конечном счете, это просто вопрос перемещения бизнес-логики из представления в то место, где она имеет смысл, руководствуясь принципами связности и целостности (или принципами SOLID в целом), что ее можно было легко протестировать. Наконец, любопытно и то, что лучший в мире курс по iOS, CS193P от Стэндфорда, все еще преподносит MVVM как главный презентационный паттерн для SwiftUI-приложений. Кроме того, Apple, похоже, тоже склоняется к тому, чтобы не использовать View в качестве модели представления. Теперь вернемся к контейнерам состояний. Я предпочитаю рассуждать о контейнерах состояний (далее также и о «Store’ах») как о «черных ящиках» с некоторыми входами и выходами. В частности, как о черных ящиках с некоторым наблюдаемым состоянием, которые также могут генерировать выходные данные. Стоит упомянуть некоторые важные решения в этом коде. Я решил использовать ObservableObject и @MainActor, чтобы мы могли быть уверены в том, что его можно корректно использовать в слое представления SwiftUI, независимо от конкретного типа состояния: состояние представления или доменного состояния. Мы должны предоставить достаточную гибкость, чтобы предоставить возможность решать, будет ли это слой представления или доменный слой. Так как будущее Combine все еще неясно, я считаю, что безопаснее и перспективнее предоставить API на основе AsyncSequence и не связывать store с Combine, по крайне мере, в этом случае. Но есть одна небольшая придирка к выбору AsyncSequence вместо Combine, которая заключается в том, что этот API получается не совсем корректным. Оба StateStream и OutputStream должны быть потоками, которые никогда не ломаются, но нет никакого способа гарантировать это с помощью типа AsyncSequence. В Combine, и благодаря новым связанным типам в протоколах, это было бы так просто и лаконично: В отличие от Combine, AsyncSequence работает с использованием асинхронных генерирующих ошибки функций, ошибки которых всегда нетипизированы по умолчанию. Интересно, что Swift разрешает соответствие протоколу, который объявляет генерирующую ошибки функцию, опуская ключевое слово throw в реализации. Это означает, что эквивалент типа Never в мире Combine будет использовать конкретную реализацию AsyncSequence с не генерирующей ошибки функцией next: К счастью, в конечном итоге мы будем использовать конкретные типы StateStream и OutputStream, так что все это не будет проблемой. Теперь пришло время рассмотреть разные формы этого StoreType. Этот раздел подводит нас к первым двум основным формам, в зависимости от того, как мы решили организовать наше состояние: Одно цельное состояние для всего приложения (подход Redux). Различные распределенные части состояния в приложении (подход Flux). У каждого подхода есть свои плюсы и минусы. Главный «минус» распределенного подхода заключается в обеспечении согласованности данных в разных контейнерах состояний, что может быть достаточно сложной задачей. Эта проблема полностью исчезает при наличии цельного значения состояния. Это огромное преимущество. Но, как и следовало ожидать, у подхода с цельным значением состояния есть свои серьезные минусы: производительность и связанность. Проблема с производительностью проявляется в двух местах. Изменение может быть достаточно дорогостоящим из-за размера значения состояния и отсутствия персистентных структур данных в Swift. Но, что более важно, любое изменение этого значения приведет к повторному рендерингу представлений во всем приложении (или множеству перерасчетов функций и Equatable проверок). Даже если SwiftUI может быть достаточно умным, чтобы не отображать некоторые элементы представления, тела ваших представлений все равно будут вызываться, и вы можете столкнуться с узкими местами производительности. Для простых приложений это может и не быть такой уж большой проблемой. Я знаю много приложений, использующих TCA (известную архитектуру iOS, реализующую подход Redux), которые отлично работают. Но в более сложных приложениях вы, вероятно, можете столкнуться с проблемами производительности, хотя вы всегда можете использовать подход с несколькими store. Для получения дополнительной информации почитайте пост в блоге Кшиштофа Заблоцкого (Krzysztof Zabłocki) “Производительность TCA и мульти-store”. Проблема связанности — это то, чем грешат многие библиотеки, использующих подход Redux. Вы просто связаны со всем значением «AppState». Некоторые библиотеки, такие как TCA, решают эту проблему, значительно усложняя архитектуру. Распространение изменений состояния и сообщений из дочерних состояний в корневое идет рука об руку с кучей «TCA DSL» и хелперов, о которых вам нужно быть в курсе: например, использование их библиотеки prisms (CasePaths) для корректной работы с перечислениями. В зависимости от вашей команды и ее знаний о парадигмах функционального программирования, цена связанности всего вашего приложения с TCA может варьироваться от приемлемой до просто непомерной. И я все еще думаю, что TCA, вероятно, одна из лучших библиотек управления состояниями, доступных для iOS в настоящее время. Что касается моих личных предпочтений, то я предпочитаю использовать контейнеры с распределенным состоянием и привязывать состояние как можно ниже в иерархии представлений, чтобы избежать повторного рендеринга и повторных вычислений тел представлений. Кроме того, очень помогает наличие правильных ограниченных контекстов и модулей, не требующих сложной синхронизации. Если вы хотите прочитать об этом больше, то я могу вам порекомендовать статью “Redux — это полупаттерн”. Начнем с самой простой формы, о которой я уже рассказывал в этом посте в блоге, и той, которая должна быть привычной для большинства разработчиков. Смешение состояния и эффектов в базовом классе Store, от которого мы будем наследоваться (при необходимости). Мы можем использовать его в самой простой форме, просто обернув некоторые значения, которые мы можем изменить и наблюдать: Обычно мы создаем от типа Store наследника (подкласс), в котором и будут эти изменения состояния и выходные данные с соответствующей бизнес-логикой. Выглядит достаточно просто. Мы наследуемся от типа Store и предоставляем методы (входные данные) для объединения бизнес-логики, выполнения побочных эффектов, изменения состояния и запуска выходных сообщений. Затем мы можем очень легко подписаться на эту NumbersViewModel как из SwiftUI (с помощью@StateObject или @ObservedObject), так и из UIKit, прослушивая StateStream и корректно рендеря состояние в представлениях UIKit. Я лично имел большой успех с первой формой. Хотя в ней и отсутствует какая-либо надлежащая инкапсуляция состояния (поскольку оно может быть изменено вне конкретного подкласса store из-за отсутствия семантики protected в Swift), она привычна, проста, гибка, дает нам точную отслеживаемость изменений состояния и удерживает их в главном потоке за счет семантики @MainActor. Я не знаю каких-либо конкретных известных библиотек iOS, использующих этот шаблон, но я знаю такие в мире Android. Mavericks Airbnb скорее всего является самой значимой. Orbit – еще один хороший пример. Но иногда просто отслеживаемости состояния недостаточно. Мы хотим знать, почему это состояние изменилось, и иметь более строгий способ управления эффектами. А для этого нам нужно немного усложнить архитектуру. Как я уже сказал, здесь все становится намного сложнее (но также и веселее). Я также уже рассказывал об этой форме в прошлом (здесь). Несмотря на свою привлекательность, простоту и гибкость предыдущий подход имеют свои недостатки. Главный из них заключается в том, что за свободу управлять логикой, изменениями состояния и эффектами, как мы хотим, приходится платить. В простых примерах, таких как предыдущий, трудно увидеть преимущество усложнения этого подхода. Но с более сложной бизнес-логикой, командами и требованиями более ограниченный и формальный подход к работе с состояниями и эффектами раскрывает свои преимущества и, по моему опыту, лучше масштабируется. Давайте сначала обрисуем некоторые требования: Нам нужен способ установить взаимосвязь между сообщениями, изменениями состояния и вызываемыми ими эффектами. Нам нужна правильная инкапсуляция различных типов сообщений. Неправильная инкапсуляция сообщений, к сожалению, очень распространена среди этих типов архитектур. Публичное сообщение onAppear – не одно и то же, что и приватное сообщение didReceiveData. Нам нужен способ правильно моделировать наши эффекты, используя async/await. За последние несколько лет появилось много (я бы сказал, слишком много) форм этих однонаправленных архитектур, каждая со своими «мнениями» и «компромиссами» в том, как они управляют состоянием и эффектами. Некоторые из них «слишком завязаны на Combine», что вынуждает вас связывать ваше приложение с этими Combine-комбинаторами. Некоторые из других форм ставят понятие «мутация» выше понятия «сообщение» (или события/действия), чтобы они могли провести различие между фактическим «изменением состояния» и «намерением изменения». Основное вдохновение этого — архитектура Vue, Vuex. ReactorKit — отличная библиотека iOS, использующая эту концепцию. Некоторые другие формы моделируют эффекты в виде «фидбэков»: простая функция (Publisher<State>) -> Publisher<Event>. ReactiveFeedback — еще одна замечательная библиотека, реализующая этот паттерн. По моему опыту, эти формы излишне усложняют вещи и делают код труднее для понимания. Особенно концепция моделирования эффектов как «пакетов фидбэков», так как она ухудшает читаемость и запутывает поток управления. Давайте представим, что мы хотим понять, что происходит в системе всякий раз, когда происходит действие: Во-первых, мы перейдем в функцию, которая обрабатывает сообщение и изменяет состояние. Эта функция обычно называется редьюсером (reducer). Затем, чтобы узнать, какие эффекты вызывает предыдущее сообщение, нам нужно проверить все «фидбэки», чтобы увидеть, вызовет ли это новое состояние какой-либо из них. Это затрудняет понимание кода и создание правильной ментальной модели, необходимой для изменения этого кода. Вот хорошая статья, объясняющая некоторых из этих проблем в архитектуре MVI. Каково решение? Вместо того, чтобы «волшебным образом запускать» эффекты на основе изменения состояния, позвольте редьюсеру решать, какие эффекты запускать. Таким образом, мы можем точно проследить за выполнением кода с момента, когда сообщение отправляется в систему, до момента, когда закончилось выполнение всех эффектов. Входное сообщение -> Состояние изменено -> Эффект -> Сообщение с фидбэком… Существуют различные способы, с помощью которых редьюсер может решить, какой эффект будет выполняться: Мы можем обернуть вычисление эффекта внутри структуры данных с правильным асинхронным контекстом. Что-то вроде этого: Так работает библиотека Pointfree TCA. Другой вариант — вернуть значение, представляющее эффект, который будет интерпретирован позже (обработчиком эффектов). Так работает библиотека Spotify Mobius, которая, если мне не изменяет память, основана на работе Энди Матушака (Andy Matuschak): Компонуемый паттерн для чистых стейт-машин с эффектами. Последний вариант — вернуть тип, соответствующий протоколу, объединяющему асинхронную работу. Так устроена архитектура Workflow Square. У каждой из этих форм есть свои плюсы и минусы. В первом подходе меньше «скачков» и очень легко увидеть результат воздействия и перейти к сообщению с фидбэком .didFinishFetching. Недостатком является то, что для сложных эффектов редьюсер может оказаться слишком большим и сложным. Но мы всегда можем вынести этот код в отдельные функции по мере необходимости. Преимущество второго подхода состоит в том, что его можно очень легко протестировать. Мы можем проверить функцию без необходимости создавать зависимости или что-либо мокать. Редьюсер становится «чистым слоем», который легко тестировать, а «обработчик эффектов» становится «нечистым слоем», который нам, возможно, не нужно тестировать, так как он должен быть простым объектом. Третий подход может иметь более автономный, целостный пакет для конкретного эффекта, объединяющий необходимые зависимости, не засоряя редьюсер всем многообразием зависимостей эффектов. Если быть до конца честным, то здесь у меня нет твердого мнения о лучшем варианте. Второй подход я использовал чаще всего, и он хорошо себя зарекомендовал. Но иногда эти «скачки» между функцией редьюсера и обработкой эффекта могут доставлять неудобства. Я рекомендую поместить их в один и тот же файл, чтобы легко переключаться между обеими частями. И о тестировании со вторым подходом… В то время как мы можем протестировать редьюсер, чтобы увидеть фактические значения эффектов, возвращаемые конкретным сообщением, этот тест может быть не самым лучшим тестом, учитывая, что эффекты являются деталями реализации (по крайней мере, с точки зрения публичного API store). Фактический «наблюдаемый API» контейнера состояния, по которому мы должны сформировать ассерт, — это кортеж (state, output). Изменение того, как мы обрабатываем наши эффекты, без изменения нашего наблюдаемого API и поведения, не должно нарушать наши тесты. Давайте теперь построим полную реализацию вышеупомянутой архитектуры. Смело копируйте все и тестируйте результат. Как я уже упоминал, правильная инкапсуляция сообщений важна и ведет к более безопасному и чистому API. Вот почему у нас есть три типа сообщений: Входные: .onAppear события для store’ов представлений или.downloadData команды для доменных store’ов. Фидбэк: .didDownloadData событие. Они являются приватными, отправляются из эффектов и не могут быть отправлены непосредственно из инстанса store. Выходные: события типа “выстрелил и забыл”, когда нам нужно сообщить информацию, которая не должна сохраняться, в отличие от состояния. Тпи Effect имеет две ответственности: Обертка асинхронной работы, самого эффект. Уведомление о любом выходном сообщении. Редьюсер будет тем, кто изменяет состояние с учетом определенного сообщения и возвращает правильный тип Effect. Где Message будет просто объединением сообщений Input и Feedback. И, наконец, Store. Давайте теперь построим предыдущий пример NumbersViewModel, используя новую архитектуру. Большое спасибо, что дошли до конца статьи. Это было долгое (и, надеюсь, интересное) чтиво. За последние годы у нас произошел бум библиотек управления состояниями. Мы видели, как однонаправленные архитектуры захватили мобильное пространство, особенно наряду с новыми декларативными фреймворками пользовательского интерфейса, такими как SwiftUI (и Jetpack Compose на Android), где они идеально раскрывают себя. Но не одним только SwiftUI… async/await также оказали большое влияние на то, как мы разрабатываем наши приложения и справляемся с параллелизмом и побочными эффектами, что повлияло на многие новые архитектуры, появившиеся за последние годы. Все это разделение управления состоянием и эффектов привело к появлению множества новых функциональных парадигм в сообществе iOS, и многие люди уже используют TCA в качестве архитектуры по умолчанию для любого нового iOS-проекта со SwiftUI. Это отличная новость, которая показывает зрелость экосистемы iOS. Хотя Swift будет продолжать развиваться, я не предвижу каких-либо серьезных изменений в ближайшие годы, которые кардинально изменят то, как мы разрабатываем приложения, или, по крайней мере, так сильно повлияют на библиотеки управления состояниями, как это произошло в последние годы. Что ж… Возможно, «Swift Data» заменит Core Data… посмотрим через несколько месяцев на WWDC23. Все больше приложений работают автономно без веб-сайтов, но это не отменяет отправки писем, из которых необходим переход в приложение, и зачастую на его конкретный экран. Для этого используются Deep links, и правильно спроектированная навигация — залог успеха использования этой технологии. А еще лучше получить практическое представление, как это сделать. Как правильно организовать навигацию с помощью Deep Links и Universal Links в SwiftUI? Обсудим это на открытом уроке.  Поговорим о технологии Deep links на iOS, произведем настройку сервера, клинтского приложения для работы с технологией. Напишим простой пример, как открыть определенный экран с помщью Deep links на примере SwiftUI-приложения. Записаться на открытый урок  Редактор