Как стать автором
Обновить
1049.66
OTUS
Цифровые навыки от ведущих экспертов

О многообразии форм однонаправленных архитектур в Swift

Время на прочтение17 мин
Количество просмотров3.2K
Автор оригинала: Luis Recuenco

Как SwiftUI и async/await изменили концепцию контейнеров состояний за последние шесть лет

Я уже писал о концепции “контейнеров состояний” в 2017 году. Шесть лет спустя мне все еще нравится строить на их основе большинство своих приложений, используя эту концепцию для двух главных слоев внутри моих приложений:

  • Слой представления: модель представления — это «контейнер состояния представления» (view state container), моделирующий состояние представления и бизнес-логику.

  • Доменный слой: обычно агрегирует корневые модели (или репозитории/сервисы данных), представляющие бизнес-правила и сохраняющие целостность и согласованность в рамках определенного ограниченного контекста (или сущности) в приложении.

Однако с 2017 года многое изменилось. В 2019 году Apple представила миру SwiftUI. И два года спустя появился async/await. Хоть мы и склонны думать, что хорошие архитектуры не должны зависеть от специфик фреймворков, хорошие архитектуры все-таки являются лояльными гражданами тех же самых фреймворков и общей экосистемы. Так… как же SwiftUI и async/await изменили концепцию контейнеров состояний за последние шесть лет? Давайте посмотрим.

Аргументы против MVVM в SwiftUI

Но сначала давайте поговорим о некоторых недавних дебатах в iOS-сообществе касательно MVVM и того, подходит ли он для SwiftUI. Основной тезис заключается в том, что «View уже и так является моделью представления», поэтому MVVM не нужен.

Я не согласен с этим тезисом. Хотя и верно то, что SwiftUI уже имеет встроенные примитивы (в виде оберток свойств), призванные упростить большую часть связующего кода для наблюдения за состоянием и повторного рендеринга, View — это просто декларативное описание того, как выглядит слой представления. Модель представления — это гораздо больше. Это то место, где обитает бизнес-логика, связанная с представлением.

Но для начала давайте ответим на следующий вопрос. Зачем нам вообще нужно переносить куда-либо код из слоя представления? Не вступая в (иногда очень субъективные) дебаты об ответственности и о том, как следует разделять программное обеспечение, кое-что и так уже предельно ясно: если мы не перенесем код из слоя представления, то модульное тестирование будет очень сложным. Но куда мы должны переместить этот код?

Мой ответ на этот вопрос, как и в большинстве случаев, – «это зависит от конкретной ситуации». Иногда такая логика может быть внутри нашего доменного контейнера состояния – ее наблюдают различные представления, которым нужна одна и та же информация. Иногда это просто какая-то бизнес-логика для представления. В этих случаях я предпочитаю помещать эту логику в модель представления (а именно в контейнер состояния представления). В других случаях речь может идти об обработке различной информации из разных источников данных и ее проверке для создания состояния представления. Для всех этих сценариев модель представления является вполне разумным решением. Это решение целесообразно, потому что модель представления объединяет всю необходимую бизнес-логику, которая важна только для этого представления. Перемещение этой специфичной для представления бизнес-логики в доменную модель, с большой долей вероятности, пойдет в ущерб ее целостности и, в конечном счете, выльется в неправильную абстракцию.

В конечном счете, это просто вопрос перемещения бизнес-логики из представления в то место, где она имеет смысл, руководствуясь принципами связности и целостности (или принципами SOLID в целом), что ее можно было легко протестировать.

Наконец, любопытно и то, что лучший в мире курс по iOS, CS193P от Стэндфорда, все еще преподносит MVVM как главный презентационный паттерн для SwiftUI-приложений.

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

Теперь вернемся к контейнерам состояний.

Форма контейнера состояния

Я предпочитаю рассуждать о контейнерах состояний (далее также и о «Store’ах») как о «черных ящиках» с некоторыми входами и выходами. В частности, как о черных ящиках с некоторым наблюдаемым состоянием, которые также могут генерировать выходные данные.

@MainActor
protocol StoreType<State, Output>: ObservableObject where State: Equatable {
    associatedtype State
    associatedtype Output
    associatedtype StateStream: AsyncSequence where StateStream.Element == State
    associatedtype OutputStream: AsyncSequence where OutputStream.Element == Output

    var stateStream: StateStream { get }
    var outputStream: OutputStream { get }

    var state: State { get set }
}

Стоит упомянуть некоторые важные решения в этом коде.

  • Я решил использовать ObservableObject и @MainActor, чтобы мы могли быть уверены в том, что его можно корректно использовать в слое представления SwiftUI, независимо от конкретного типа состояния: состояние представления или доменного состояния. Мы должны предоставить достаточную гибкость, чтобы предоставить возможность решать, будет ли это слой представления или доменный слой.

  • Так как будущее Combine все еще неясно, я считаю, что безопаснее и перспективнее предоставить API на основе AsyncSequence и не связывать store с Combine, по крайне мере, в этом случае.

Но есть одна небольшая придирка к выбору AsyncSequence вместо Combine, которая заключается в том, что этот API получается не совсем корректным. Оба StateStream и OutputStream должны быть потоками, которые никогда не ломаются, но нет никакого способа гарантировать это с помощью типа AsyncSequence. В Combine, и благодаря новым связанным типам в протоколах, это было бы так просто и лаконично:

associatedtype StateStream: Publisher<State, Never>
associatedtype OutputStream: Publisher<Output, Never>

В отличие от Combine, AsyncSequence работает с использованием асинхронных генерирующих ошибки функций, ошибки которых всегда нетипизированы по умолчанию.

mutating func next() async throws -> Self.Element?

Интересно, что Swift разрешает соответствие протоколу, который объявляет генерирующую ошибки функцию, опуская ключевое слово throw в реализации. Это означает, что эквивалент типа Never в мире Combine будет использовать конкретную реализацию AsyncSequence с не генерирующей ошибки функцией next:

mutating func next() async -> Self.Element?

К счастью, в конечном итоге мы будем использовать конкретные типы 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, от которого мы будем наследоваться (при необходимости).

@dynamicMemberLookup
class Store<State, Output>: StoreType where State: Equatable {
    private let stateSubject: CurrentValueSubject<State, Never>
    lazy var stateStream = stateSubject.removeDuplicates().values

    private let outputSubject: PassthroughSubject<Output, Never> = .init()
    lazy var outputStream = outputSubject.values

    init(state: State) {
        self.state = state
        stateSubject = .init(state)
    }

    @Published
    var state: State {
        didSet {
            if !Thread.isMainThread {
                // Хоть Store является главным актором, у нас могут быть некоторые неизолированные контексты, в которых состояние
                // не изменяется в основном потоке. Эту проблему можно свести к минимуму, установив «Strict Concurrency Checking» 
                // в значение "complete".
                assertionFailure("Not on main thread")
            }

            // Здесь у нас есть отслеживаемость состояния, и мы можем зарегистрировать его, если это необходимо.
        }
    }

    func send(output: Output) {
        outputSubject.send(output)
    }

    subscript<Value>(dynamicMember keypath: KeyPath<State, Value>) -> Value {
        state[keyPath: keypath]
    }
}

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

enum Output { case somethingHappened }
let store = Store<Int, Output>(state: 0)

Task {
    for await state in store.stateStream {
        print("New state", state)
    }
}

Task {
    for await output in store.outputStream {
        print("New output", output)
    }
}

store.state = 1
store.send(output: .somethingHappened)

Обычно мы создаем от типа Store наследника (подкласс), в котором и будут эти изменения состояния и выходные данные с соответствующей бизнес-логикой.

enum NumbersViewState: Equatable {
    case idle
    case loading
    case loaded([Int])
    case error
}

enum NumbersViewOutput {
    case numbersDownloaded
}

final class NumbersViewModel: Store<NumbersViewState, NumbersViewOutput> {
    init() {
        super.init(state: .idle)
    }

    func load() async {
        state = .loading

        do {
            let numbers = try await apiClient.numbers()
            state = .loaded(numbers)

            // В этом случае выходное сообщение не добавляет ничего значимого, а просто служит
            // примером какого-нибудь события типа "выстрелил и забыл", которое может быть полезным для вещей, которые, в отличие от состояния, не являются постоянными.
            send(output: .numbersDownloaded)
        } catch {
            state = .error
        }        
    }
}

Выглядит достаточно просто. Мы наследуемся от типа Store и предоставляем методы (входные данные) для объединения бизнес-логики, выполнения побочных эффектов, изменения состояния и запуска выходных сообщений.

Затем мы можем очень легко подписаться на эту NumbersViewModel как из SwiftUI (с помощью@StateObject или @ObservedObject), так и из UIKit, прослушивая StateStream и корректно рендеря состояние в представлениях UIKit.

Task {
    for await state in numbersViewModel.stateStream {
        switch state {
        case .idle, .loading:
            loadingView.isHidden = false
            numbersView.isHidden = true
            errorView.isHidden = true

        case .loaded(let numbers):
            loadingView.isHidden = true
            errorView.isHidden = true
            numbersView.isHidden = false
            numbersView.render(with: numbers)

        case .error:
            loadingView.isHidden = true
            numbersView.isHidden = true
            errorView.isHidden = false
        }
    }
}

Я лично имел большой успех с первой формой. Хотя в ней и отсутствует какая-либо надлежащая инкапсуляция состояния (поскольку оно может быть изменено вне конкретного подкласса 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.

Каково решение? Вместо того, чтобы «волшебным образом запускать» эффекты на основе изменения состояния, позвольте редьюсеру решать, какие эффекты запускать.

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

Входное сообщение -> Состояние изменено -> Эффект -> Сообщение с фидбэком…

Различные способы управления эффектами

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

  • Мы можем обернуть вычисление эффекта внутри структуры данных с правильным асинхронным контекстом. Что-то вроде этого:

func handle(event: Event, state: inout State) -> Effect<Event> {
    switch event {
    case .onAppear:
        state = .loading
        return .task { send in
            let numbers = await apiClient.numbers()
            send(.didFinishFetching(numbers))
        }

    case .didFinishFetching(let numbers):
        state = .loaded(numbers)
        return .none
    }
}

Так работает библиотека Pointfree TCA.

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

func handle(event: Event, state: inout State) -> [Effect] {
    switch event {
    case .onAppear:
        state = .loading
        return [.downloadNumbers]

    case .didFinishFetching(let numbers):
        state = .loaded(numbers)
        return .none
    }
}

class EffectHandler {
    func handle(effect: Effect) async {
        switch effect {
        case .downloadNumbers:
            let numbers = await apiClient.numbers()
            send(.didFinishFetching(numbers))
        }
    }
}

Так работает библиотека Spotify Mobius, которая, если мне не изменяет память, основана на работе Энди Матушака (Andy Matuschak): Компонуемый паттерн для чистых стейт-машин с эффектами.

  • Последний вариант — вернуть тип, соответствующий протоколу, объединяющему асинхронную работу.

func handle(event: Event, state: inout State) -> [Effect] {
    switch event {
    case .onAppear:
        state = .loading
        return [DownloadNumbers()]

    case .didFinishFetching(let numbers):
        state = .loaded(numbers)
        return .none
    }
}


class DownloadNumbers: Effect {
    func run() async {
        let numbers = await apiClient.numbers()
        send(.didFinishFetching(numbers))
    }
}

Так устроена архитектура Workflow Square.

У каждой из этих форм есть свои плюсы и минусы.

  • В первом подходе меньше «скачков» и очень легко увидеть результат воздействия и перейти к сообщению с фидбэком .didFinishFetching. Недостатком является то, что для сложных эффектов редьюсер может оказаться слишком большим и сложным. Но мы всегда можем вынести этот код в отдельные функции по мере необходимости.

  • Преимущество второго подхода состоит в том, что его можно очень легко протестировать. Мы можем проверить функцию без необходимости создавать зависимости или что-либо мокать. Редьюсер становится «чистым слоем», который легко тестировать, а «обработчик эффектов» становится «нечистым слоем», который нам, возможно, не нужно тестировать, так как он должен быть простым объектом.

  • Третий подход может иметь более автономный, целостный пакет для конкретного эффекта, объединяющий необходимые зависимости, не засоряя редьюсер всем многообразием зависимостей эффектов.

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

И о тестировании со вторым подходом… В то время как мы можем протестировать редьюсер, чтобы увидеть фактические значения эффектов, возвращаемые конкретным сообщением, этот тест может быть не самым лучшим тестом, учитывая, что эффекты являются деталями реализации (по крайней мере, с точки зрения публичного API store). Фактический «наблюдаемый API» контейнера состояния, по которому мы должны сформировать ассерт, — это кортеж (state, output). Изменение того, как мы обрабатываем наши эффекты, без изменения нашего наблюдаемого API и поведения, не должно нарушать наши тесты.

Окончательная реализация

Давайте теперь построим полную реализацию вышеупомянутой архитектуры. Смело копируйте все и тестируйте результат.

Как я уже упоминал, правильная инкапсуляция сообщений важна и ведет к более безопасному и чистому API. Вот почему у нас есть три типа сообщений:

  • Входные: .onAppear события для store’ов представлений или.downloadData команды для доменных store’ов.

  • Фидбэк: .didDownloadData событие. Они являются приватными, отправляются из эффектов и не могут быть отправлены непосредственно из инстанса store.

  • Выходные: события типа “выстрелил и забыл”, когда нам нужно сообщить информацию, которая не должна сохраняться, в отличие от состояния.

Тпи Effect имеет две ответственности:

  • Обертка асинхронной работы, самого эффект.

  • Уведомление о любом выходном сообщении.

struct Effect<Feedback, Output> {
    typealias Operation = (@Sendable @escaping (Feedback) async -> Void) async -> Void
    
    fileprivate let output: Output?
    fileprivate let operation: Operation

    init(
        output: Output?,
        operation: @escaping Operation
    ) {
        self.output = output
        self.operation = operation
    }
}

extension Effect {
    static var none: Self {
        return .init(output: nil) { _ in }
    }

    static func output(_ output: Output) -> Self {
        return .init(output: output) { _ in }
    }

    static func run(operation: @escaping Operation) -> Self {
        self.init(output: nil, operation: operation)
    }
}

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

@MainActor
protocol Reducer<State, Input, Feedback, Output>: AnyObject where State: Equatable {
    associatedtype State
    associatedtype Input
    associatedtype Feedback = Never
    associatedtype Output = Never

    func reduce(
        message: Message<Input, Feedback>,
        into state: inout State
    ) -> Effect<Feedback, Output>
}

Где Message будет просто объединением сообщений Input и Feedback.

enum Message<Input, Feedback>: Sendable where Input: Sendable, Feedback: Sendable {
    case input(Input)
    case feedback(Feedback)
}

И, наконец, Store.

import Combine

@MainActor
@dynamicMemberLookup
class Store<State, Input, Feedback, Output>: ObservableObject where State: Equatable, Input: Sendable, Feedback: Sendable {
    @Published
    private(set) var state: State

    private let reducer: any Reducer<State, Input, Feedback, Output>

    private let stateSubject: CurrentValueSubject<State, Never>
    lazy var stateStream = stateSubject.removeDuplicates().values

    private let outputSubject: PassthroughSubject<Output, Never> = .init()
    lazy var outputStream = outputSubject.values

    private var tasks: [Task<Void, Never>] = []

    deinit {
        for task in tasks {
            task.cancel()
        }
    }

    init(
        state: State,
        reducer: some Reducer<State, Input, Feedback, Output>
    ) {
        self.state = state
        self.reducer = reducer
        stateSubject = .init(state)
    }

    @discardableResult
    func send(_ message: Input) -> Task<Void, Never> {
        let task = Task { await send(.input(message)) }
        tasks.append(task)
        return task
    }

    func send(_ message: Input) async {
        await send(.input(message))
    }

    private func send(_ message: Message<Input, Feedback>) async {
        guard !Task.isCancelled else { return }

        let effect = reducer.reduce(message: message, into: &state)
        stateSubject.send(state)

        if let output = effect.output {
            outputSubject.send(output)
        }

        await effect.operation { [weak self] feedback in
            guard !Task.isCancelled else { return }

            await self?.send(.feedback(feedback))
        }
    }

    subscript<Value>(dynamicMember keypath: KeyPath<State, Value>) -> Value {
        state[keyPath: keypath]
    }
}

Давайте теперь построим предыдущий пример NumbersViewModel, используя новую архитектуру.

import Foundation

final class APIClient {
    func numbers() async throws -> [Int] {
        try await Task.sleep(nanoseconds: 1 * NSEC_PER_SEC)
        return [1, 2, 3]
    }
}

final class NumbersViewReducer: Reducer {
    enum State: Equatable {
        case idle
        case loading
        case loaded([Int])
        case error
    }

    enum Input {
        case onAppear
    }

    enum Feedback {
        case numbersDownloaded(Result<[Int], Error>)
    }

    enum Output {
        case numbersDownloaded
    }

    private let apiClient = APIClient()

    func reduce(message: Message<Input, Feedback>, into state: inout State) -> Effect<Feedback, Output> {
        switch message {
        case .input(.onAppear):
            state = .loading
            return .run { [weak self] send in
                guard let self else { return }

                do {
                    let numbers = try await apiClient.numbers()
                    await send(.numbersDownloaded(.success(numbers)))
                } catch {
                    await send(.numbersDownloaded(.failure(error)))
                }
            }

        case .feedback(.numbersDownloaded(.success(let values))):
            state = .loaded(values)
            return .output(.numbersDownloaded)

        case .feedback(.numbersDownloaded(.failure)):
            state = .error
            return .none
        }
    }
}

import SwiftUI

struct NumbersListView: View {
    @StateObject private var viewModel = Store(
        state: NumbersViewReducer.State.idle,
        reducer: NumbersViewReducer()
    )

    var body: some View {
        Group {
            switch viewModel.state {
            case .idle, .loading:
                Text("…")

            case .loaded(let values):
                List(values, id: \.self) { value in
                    Text("\(value)")
                }

            case .error:
                Text("Some error happened")
            }
        }.onAppear {
            viewModel.send(.onAppear)
        }
    }
}

Заключение

Большое спасибо, что дошли до конца статьи. Это было долгое (и, надеюсь, интересное) чтиво.

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

Теги:
Хабы:
Всего голосов 8: ↑7 и ↓1+6
Комментарии0

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS