Привет! Я часто слышу, что шаблон MVC не идеален, но он идеально подходит для небольших проектов. И я полностью согласен с этим утверждением. Но в реальной жизни в мире iOS-разработки почти не осталось мелких проектов. Приложения становятся все больше и больше с каждым годом, а требования бизнеса и пользователей к мобильным приложениям растут в геометрической прогрессии.

Вот почему современная архитектура приложений должна иметь возможность масштабироваться и удовлетворять потребности разработчиков. Я изучил много разных подходов к достижению масштабируемости. Но способ TCA сейчас мой любимый.

Проблемы с шаблонами M+

Здесь мы можем сослаться на MVC, MVVM, MVP и другие M-подобные шаблоны. Что у них общего — их легко изучить и реализовать, и они отлично работают для локальных изолированных функций. Но когда приложение начинает расти, могут возникнуть некоторые проблемы.

Среди них:

  • передача данных между отдельными частями приложения
  • как нам следует соединить функции
  • интеграционные тесты
  • модульность

Представьте ситуацию: у вас есть сложный навигационный стек с разными представлениями на 20 экранах. И когда вы нажимаете кнопку на последнем экране, вам нужно как-то реагировать на это нажатие на первом экране. Как вы можете достичь этого? Есть разные подходы, которые я использовал.

Что вы можете использовать здесь:

  • общая шина данных, например Центр уведомлений
  • передать закрытие/делегировать/издателя на каждый экран
  • попробуйте какой-нибудь сервис-ориентированный подход (SOA) для передачи данных
  • или используйте цепочку ответчиков и метод sendAction(#selector)

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

Что такое ТЦА

Компонуемая архитектура — это Swift-адаптация платформы Redux, которая идеально сочетается со SwiftUI, UIKit, Joint и даже Modern Concurrency (async/await). Он следует подходу однонаправленного потока данных.

Концепции TCA очень просты, я обещаю, они вам понравятся:

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

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

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

Компоненты TCA: состояние, действие, редуктор, среда, эффект и хранилище.

Основные составные движущиеся части — это, по сути, редуктор и магазин. Среда, состояние и действия определяются пользователем и выполняют первые два.

Состояние — структурная модель вашего приложения. Он может содержать некоторые данные, такие как список новостей, логические свойства isLoading, которые показывают или скрывают индикатор счетчика, вложенные состояния других функций приложения. Большинство свойств определены как переменные, поэтому мы можем легко изменить три значения. Соответствует протоколу Equatable.

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

Reducer — это сущность, представленная чистой функцией, в которой может выполняться вся бизнес-логика. Редюсер принимает действия и на их основе изменяет состояние (поэтому мы помечаем свойства как var, а не let). Также он возвращает специальный тип под названием Effect (EffectTask в новой версии платформы).

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

Магазин объединяет все воедино. Просто проверьте, что принимает его инициализатор. Для этого требуется начальное состояние и редуктор. Store получает какое-то действие, затем передает его в редуктор вместе с текущим состоянием и на основе этой мутации получает новое состояние.

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

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

Я не буду сосредотачиваться на всем шаблонном коде представления, вместо этого я объясню, как все связано друг с другом. Во-первых, мы создадим наш главный экран ListFeature, определив структуру с типами State и Action внутри, которая соответствует ReleaserProtocol. Дополнительно сюда нужно добавить редуктор, и есть два варианта: определение свойства body типа some ReducerProtocolOf<Self> или функции reduce. Лично я больше предпочитаю подход var body, поскольку он похож на собственное свойство SwiftUI body.

Внутри состояния у нас будет всего три свойства: news, page и isLoading. Этого достаточно, чтобы описать наш экран. Теперь нам нужно подумать о Действиях, которые могут повлиять на наше мнение. Вот некоторые примеры:

public struct ListFeature: ReducerProtocol {
    //MARK: - Environment
    private let environment: ListEnvironment = .init(
        newsService: NewsService(
            network: Networking(
                session: .shared
            )
        )
    )
    //MARK: - State
    public struct State: Equatable {
        public var page: Int = 1
        public var news: [NewsModel]
        public var isLoading: Bool = false
        public init(news: [NewsModel]) {
            self.news = news
        }
    }
    //MARK: - Action
    public enum Action: Equatable {
        case start
        case didScrollToBottom
        case details(NewsModel)
        case onAppear(NewsModel)
        case newsResponse(Result<[NewsModel], NAError>)
    }
    //MARK: - Reducer
    public var body: some ReducerProtocol<State, Action> {
        Reduce { state, action in
            switch action {
            case .start, .didScrollToBottom:
                state.isLoading = true
                return environment
                    .newsService
                    .news(for: state.page)
                    .receive(on: DispatchQueue.main)
                    .catchToEffect(Action.newsResponse)
            case let .newsResponse(.success(news)):
                state.isLoading = false
                state.page += 1
                state.news += news
                return .none
            case let .newsResponse(.failure(error)):
                state.isLoading = false
                return .none
            case .details:
                return .none
            case .onAppear(let post):
                return post == state.news.last ?
                EffectTask(value: Action.didScrollToBottom) : .none
            }
        }
    }
    public init() {}
}

Также давайте создадим среду, которая будет содержать код, связанный с загрузкой новостей из Интернета и нумерацией страниц. Теперь пришло время самой интересной части — Редюсера. Он принимает две вещи: входное состояние и действие. Внутри свойства body давайте определим редуктор с параметрами состояния и действия и переключим действие. Представьте себе некоторую логику, которую можно добавить к каждому случаю возможных действий.

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

return environment
                  .newsService
                  .news(for: state.page)
                  .receive(on: DispatchQueue.main)
                  .catchToEffect(Action.newsResponse)

Вот как мы боремся с побочными эффектами. Эта эффектная задача с действием внутри будет передана обратно в функцию редуктора и изменит состояние и/или создаст новый эффект. В случае, если у нас нет побочных эффектов (см. подробности в редукторе), мы можем просто вернуть специальный Effect.none из Редюсера, указывающий, что мы закончили и нам не нужно снова вызывать редуктор.

Этот эффект NewsService приведет нас к новому варианту действия newsResponse. Внутри него есть тип Result, поэтому мы можем перейти отсюда к случаям успеха и неудачи. В случае неудачи мы каким-то образом обработаем ошибку. В случае успеха мы можем извлечь новые сообщения в блоге и добавить их в наш массив существующих сообщений, а также не забыть увеличить текущую страницу на 1. Кажется, основная работа завершена: мы можем загружать новые сообщения из web и обновлять наше состояние новыми сообщениями. Также мы можем переключать свойство isLoading, чтобы указать статус загрузки предстоящих публикаций. Теперь посмотрим, как обстоят дела на уровне пользовательского интерфейса.

import SwiftUI
import Combine
import ComposableArchitecture

//MARK: - View
public struct ListView: View {
    private let store: StoreOf<ListFeature>
    
    public init(store: StoreOf<ListFeature>) {
        self.store = store
    }
    
    public var body: some View {
        WithViewStore(self.store) { viewStore in
            List(viewStore.news, id: \.id) { post in
                ListItem(model: .init(post: post))
                    .onTapGesture { viewStore.send(.details(post)) }
                    .onAppear { viewStore.send(.onAppear(post)) }
            }.toolbar {
                if viewStore.isLoading {
                    ProgressView()
                }
            }
        }
    }
}

ListView инициализируется с помощью StoreOf<ListFeature>. А чтобы иметь возможность реагировать на изменения состояния, нам следует обернуть это хранилище в обертку WithViewStore. Внутри WithViewStore у нас есть параметр viewStore, который служит двум целям:

  • наблюдать за изменениями состояния
  • отправить некоторые действия

Например, в onAppear из ListItem мы отправляем в магазин действие ListFeature.Action.onAppear. Как описано выше, это действие вызовет новый запрос на публикацию в блоге, если мы достигнем конца списка.

Намекать! Мы можем оптимизировать некоторые вещи здесь. Если нам нужно наблюдать только одно свойство состояния, мы можем вызвать WithViewStore с параметром observe, передав его замыканию. Таким образом, наше представление будет реагировать только на отдельные изменения свойств, а не на изменения всего состояния. Также изучите другие оболочки, такие как SwitchStore, IfLetStore и другие. Они могут быть весьма полезны для моделирования некоторых нестандартных состояний.

Объединение редукторов

Сила TCA заключается в объединении редукторов. В целом наше приложение будет выглядеть как дерево или граф, поэтому мы сможем добраться до любого узла из любой начальной точки. Перейдите в AppFeature и проверьте, как реализован его редуктор.

import Combine
import SwiftUI
import Foundation
import ListFeature
import DetailsFeature
import ComposableArchitecture

public struct AppFeature: ReducerProtocol {
    //MARK: - State
    public struct State: Equatable {
        public enum Route: Hashable {
            case details
        }
        var path: [Route] = []
        var listState: ListFeature.State
        var detailsState: DetailsFeature.State?
        static let initial: Self = .init(
            path: [],
            listState: .init(news: []),
            detailsState: .none
        )
    }
    //MARK: - Action
    public enum Action: Equatable {
        case list(ListFeature.Action)
        case details(DetailsFeature.Action)
        case path([State.Route])
    }
    //MARK: - Reducer
    public var body: some ReducerProtocolOf<Self> {
        Reduce { state, action in
            switch action {
            case let .list(.details(post)):
                state.detailsState = .init(post: post)
                state.path = [.details]
                return .none
            case .path(let newPath):
                state.path = newPath
                return .none
            default:
                return .none
            }
        }
        
        Scope(
            state: \.listState,
            action: /Action.list
        ) {
            ListFeature()
        }
    }
}

Вы можете увидеть простой редуктор и некоторый объем. Этот объект Scope действует как мост, поэтому вы можете соединить дочерние редукторы с родительскими. Scope принимает три параметра: WritableKeyPath для дочернего состояния, CasePath для действия и дочерний РедукторПротокол.

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

Здесь мы добились того, что теперь наши дочерние и родительские редюсеры связаны. И мы можем реагировать на дочерние действия внутри родительского редуктора, используя вложенные случаи перечисления. Посмотрите на случай let .list(.details(post)) — именно так действия передаются от дочернего элемента к родительскому. Нажатие на какую-либо публикацию на странице списка вызовет это действие в редукторе AppFeature и приведет к переходу на страницу сведений. Также мы можем обновить состояние дочернего элемента из родительского редуктора на случай, если нам понадобится использовать некоторые данные из восходящего потока.

import SwiftUI
import ListFeature
import DetailsFeature
import ComposableArchitecture

struct AppView: View {
    let store: StoreOf<AppFeature>
    
    var body: some View {
        WithViewStore.init(self.store) { viewStore in
            NavigationStack(
                path: viewStore.binding(
                    get: \.path,
                    send: .path([])
                )
            ) {
                ListView(
                    store: store.scope(
                        state: \.listState,
                        action: AppFeature.Action.list
                    )
                )
                .navigationBarHidden(false)
                .onAppear {
                    viewStore.start()
                }
                .navigationDestination(for: AppFeature.State.Route.self) { route in
                    switch route {
                    case .details:
                        IfLetStore(
                            store.scope(
                                state: \.detailsState,
                                action: AppFeature.Action.details
                            )
                        ) { store in
                            DetailsPage(store: store)
                        }
                    }
                }
            }
        }
    }
}

Вот код представления, описывающий, как перемещаться между сценами. Используя необязательную обертку вокруг состояния DetailsFeature, мы можем показать или скрыть его в зависимости от его значения. Когда мы находимся на экране списка, DetailsFeature равен нулю, но когда мы нажимаем на какое-либо сообщение, DetailsFeature инициализируется и представляется в NavigationStack. Необязательное состояние — хороший кандидат для переноса в IfLetStore.

Эпилог

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

Спасибо за прочтение! Полную версию проекта можно найти на Github — NewsApp. Посетите Репозиторий составной архитектуры PointFree на Github, где вы можете найти исходный код, идеи и примеры. Также свяжитесь со мной в LinkedIn.