Фреймворк The Composable Architecture

The Composable Architecture  —  это идеальный фреймворк для создания сложных приложений в SwiftUI. Рассмотрим его функционал, преимущества, последние разработки, соображения о применении и план освоения.

Введение в TCA

TCA  —  унифицированный, понятный способ создания приложений с учетом компоновки, тестирования и эффективности, применяемый в SwiftUI, UIKit, других фреймворках и на любых платформах Apple: iOS, macOS, tvOS, watchOS.

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

  • Управление состояниями приложения с простыми типами значений вызовом этих состояний в разных интерфейсах: изменения внутри одного интерфейса немедленно отражаются на другом.
  • Компоновка: разбиение сложных функций на небольшие, выполняемые независимо компоненты с последующим их объединением в исходную функциональность.
  • Побочные эффекты для взаимодействия частей приложения с внешним миром самым тестируемым и понятным способом.
  • Тестирование функционала, а также его интеграционное тестирование с другими функциями для создания функций посложнее, сквозное тестирование для понимания влияния побочных эффектов на приложение. Так обеспечивается соответствие бизнес-логики ожиданиям.
  • Эргономика: чтобы достичь всего этого, используется минимум концепций и подвижных частей, а также простой API.

В статье не рассматриваются понятия состояния, экшена, редьюсера и хранилища.

Функционал и преимущества TCA

Мощные возможности компоновки

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

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

Так называемая компоновка  —  это процесс склеивания независимых компонентов в более полную функцию в соответствии с заранее заданными иерархией и логикой.

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

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

Вот инструменты TCA для компоновки:

CasePaths

Это такая версия keyPath с перечислениями.

В других Redux-подобных фреймворках, чтобы сопоставить экшены различных компонентов при компоновке родительских и дочерних компонентов, необходимо два отдельных замыкания:

func lift<LiftedState, LiftedAction, LiftedEnvironment>(
keyPath: WritableKeyPath<LiftedState, AppState>,
extractAction: @escaping (LiftedAction) -> AppAction?, // Преобразуем экшен дочернего компонента в экшен родительского
embedAction: @escaping (AppAction) -> LiftedAction, // Преобразуем родительский экшен в дочерний
extractEnvironment: @escaping (LiftedEnvironment) -> AppEnvironment
) -> Reducer<LiftedState, LiftedAction, LiftedEnvironment> {
.init { state, action, environment in
let environment = extractEnvironment(environment)
guard let action = extractAction(action) else {
return Empty(completeImmediately: true).eraseToAnyPublisher()
}
let effect = self(&state[keyPath: keyPath], action, environment)
return effect.map(embedAction).eraseToAnyPublisher()
}
}

let appReducer = Reducer<AppState,AppAction,AppEnvironment>.combine(
childReducer.lift(keyPath: \.childState, extractAction: {
switch $0 { // Необходимо сопоставить экшен каждого дочернего компонента отдельно
case .childAction(.increment):
return .increment
case .childAction(.decrement):
return .decrement
default:
return .noop
}
}, embedAction: {
switch $0 {
case .increment:
return .childAction(.increment)
case .decrement:
return .childAction(.decrement)
default:
return .noop
}
}, extractEnvironment: {$0}),
parentReducer
)

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

enum ParentAction {
case ...
case childAction(ChildAction)
}

let appReducer = Reducer<AppState,AppAction,AppEnvironment>.combine(
counterReducer.pullback(
state: \.childState,
action: /ParentAction.childAction, // Сопоставление прямо через «CasePaths»
environment: { $0 }
),
parentReducer
)

IdentifiedArray

IdentifiedArray  —  это тип класса array с характеристиками словаря, всем функционалом массива и аналогичной производительностью. Элементы в нем должны соответствовать протоколу «Identifiable», а идентификатор в IdentifiedArray  —  быть уникальным. Так разработчики получают доступ к данным прямо через идентификатор элемента, как в случае со словарем, без индекса.

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

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

Например:

struct ParentState:Equatable {
var cells: IdentifiedArrayOf<CellState> = []
}

enum ParentAction:Equatable {
case cellAction(id:UUID,action:CellAction) // Чтобы сопоставить дочерние экшены, используя «id» элемента как идентификатор, создаем «case» в родительском компоненте
case delete(id:UUID)
}
struct CellState:Equatable,Identifiable { // Элементы с соответствием протоколу «Identifiable»
var id:UUID
var count:Int
var name:String
}
enum CellAction:Equatable{
case increment
case decrement
}
let parentReducer = Reducer<ParentState,ParentAction,Void>{ state,action,_ in
switch action {
case .cellAction:
return .none
case .delete(id: let id):
state.cells.remove(id:id) // Чтобы избежать ошибок индекса или нештатных ситуаций, работаем с «IdentifiedArray», как со словарем
return .none
}
}
let childReducer = Reducer<CellState,CellAction,Void>{ state,action,_ in
switch action {
case .increment:
state.count += 1
return .none
case .decrement:
state.count -= 1
return .none
}
}
lazy var appReducer = Reducer<ParentState,ParentAction,Void>.combine(
//
childReducer.forEach(state: \.cells, action: /ParentAction.cellAction(id:action:), environment: { _ in () }),
parentReducer
)
// «ForEachStore» применяется в представлении непосредственно для разделения
ForEachStore(store.scope(state: \.cells,action: ParentAction.cellAction(id: action:))){ store in
CellVeiw(store:store)
}

WithViewStore

Кроме различных методов компоновки и разделения, применяемых к редьюсеру и хранилищу, в TCA имеется инструмент WithViewStore специально для дальнейшего разделения внутри представлений SwiftUI.

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

struct TestCellView:View {
let store:Store<CellState,CellAction>
var body: some View {
VStack {
WithViewStore(store,observe: \.count){ viewState in // В «count» изменения лишь наблюдаются. Даже если свойство «name» в «cellState» изменится, это представление не обновится.
HStack {
Button("-"){viewState.send(.decrement)}
Text(viewState.state,format: .number)
Button("-"){viewState.send(.increment)}
}
}
}
}
}

Таких инструментов много, подробнее о них  —  в официальной документации TCA.

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

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

Для побочных эффектов во фреймворке имеется две службы.

  • Внедрение зависимостей.

В TCA внешние среды внедрялись, как в большинстве других фреймворков. Однако в новой версии 0.41.0 внедрение зависимостей существенно изменилось, подробнее об этом  —  далее.

  • Обертывание побочных эффектов и управление ими.

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

До версии 0.40.0, чтобы преобразовать код обработки побочных эффектов в принимаемый в TCA эффект, разработчики оборачивали этот код в Publisher. Теперь же применяются предустановленные методы эффекта: run, task, FireAndForget и т. д.  —  прямо с асинхронным кодом на синтаксисе async/await, чем серьезно снижаются затраты на обертывание побочных эффектов.

Кроме того, в TCA имеется немало предустановленных эффектов для облегчения работы со сценариями, в которых много сложных побочных эффектов: timer, cancel, debounce, merge, concatenate и других.

То есть в TCA представлен полноценный механизм управления побочными эффектами. Чтобы удовлетворить требования различных сценариев, достаточно небольшой части кода в редьюсере.

Удобный инструмент тестирования

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

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

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

Для этого в TCA имеется тип TestStore специально для тестирования и соответствующее расширение «DispatchQueue». С TestStore разработчики выполняют такие операции, как отправка экшена, получение mock-объекта экшена и сравнение изменений состояния на виртуальной временно́й шкале. Так стабилизируется тестовая среда, и в некоторых случаях асинхронное тестирование преобразовывается в синхронное, серьезно сокращая время тестирования. Например, следующий код написан с применением метода «Protocol» версии 0.41.0:

struct DemoReducer: ReducerProtocol {
struct State: Equatable {
var count: Int
}

enum Action: Equatable {
case onAppear
case timerTick
}
@Dependency(\.mainQueue) var mainQueue // Внедряем зависимости
var body: some ReducerProtocol<State, Action> {
Reduce { state, action in
switch action {
case .onAppear:
return .run { send in
while !Task.isCancelled {
try await mainQueue.sleep(for: .seconds(1)) // Для удобного тестирования используем очередь зависимости
await send(.timerTick)
}
}
case .timerTick:
state.count += 1
return .none
}
}
}
}
@MainActor
final class TCA_DemoReducerTests: XCTestCase {
func testDemoStore() async {
// Создаем «TestStore»
let testStore = TestStore(initialState: DemoReducer.State(count: 0), reducer: DemoReducer())
// Создаем тестовую очередь. «TestSchedulerOf<DispatchQueue>» — это расширение «DispatchQueue» из TCA для удобного модульного тестирования с функцией настройки времени
let queue = DispatchQueue.test
testStore.dependencies.mainQueue = queue.eraseToAnyScheduler() // Переходим к тестированию зависимости
let task = await testStore.send(.onAppear) // Отправляем экшен «onAppear»
await queue.advance(by:.seconds(3)) // Время на прогон — три секунды: не занимает 3 с. времени тестирования и выполняется синхронно
_ = await testStore.receive(.timerTick){ $0.count = 1} // Получаем три экшена «timerTick» и сравниваем изменения состояния
_ = await testStore.receive(.timerTick){ $0.count = 2}
_ = await testStore.receive(.timerTick){ $0.count = 3}
await task.cancel() // Завершаем задачу
}
}

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

Кроме TestStore, в TCA имеются объявление нереализованных методов зависимостей XCTUnimplemented, новые утверждения для тестирования и инструмент SnapshotTesting для легкого создания разработчиками скриншотов.

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

Активное сообщество и обширные ресурсы

Сейчас TCA  —  самый популярный среди подобных фреймворк на языке Swift. На момент написания этой статьи у TCA 7200 звезд на GitHub и очень активное сообщество, где проблемы решаются быстро.

TCA появился из видеокурсов Point Free, там много видеоконтента о проблемах текущей разработки TCA, решениях, планировании, деталях реализации и многом другом. Вряд ли найдется еще фреймворк с таким обширным сопутствующим контентом, полезным для постепенного понимания и освоения разработчиками всех аспектов TCA, участия в работе его сообщества. У сообщества и Point Free очень хорошее взаимопродвижение.

Последние изменения в TCA от версии 0.40.0

Недавно TCA подвергся двум большим обновлениям: версии 0.40.0 и 0.41.0. Рассмотрим некоторые из них.

Доработана поддержка асинхронности

До версии 0.40.0 разработчики оборачивали побочные эффекты в Publishers. В итоге код разрастался, затруднялось применение большего числа API с механизмом async/await. С этим обновлением разработчики используют современные API прямо в эффекте редьюсера: не только сокращается код, но и появляется доработанный механизм координации потоков Swift.

SwiftUI-модификатором task в TCA управляется жизненный цикл эффектов, которым автоматически требуются длительно выполняемые задачи.

«onAppear» и «onDisappear» за время существования представления появляются неоднократно, поэтому жизненный цикл поддерживаемого task эффекта может не соответствовать представлению.

Например, такой код после версии 0.40.0 понятнее и естественнее:

// Старая версия
switch action {
case .userDidTakeScreenshotNotification:
state.screenshotCount += 1
return .none

case .onAppear:
return environment.notificationCenter
.publisher(for: UIApplication.userDidTakeScreenshotNotification)
.map { _ in LongLivingEffectsAction.userDidTakeScreenshotNotification }
.eraseToEffect()
.cancellable(id: UserDidTakeScreenshotNotificationId.self)
case .onDisappear:
return .cancel(id: UserDidTakeScreenshotNotificationId.self)
}

// в представлении
Text("Hello")
.onAppear { viewStore.send(.onAppear) }
.onDisappear { viewStore.send(.onDisappear) }

Применяя режим Task:

switch action {
case .task:
return .run { send in
for await _ in await NotificationCenter.default.notifications(named: UIApplication.userDidTakeScreenshotNotification).values { // считываем из AsyncStream
await send(.userDidTakeScreenshotNotification)
}
}

case .userDidTakeScreenshotNotification:
state.screenshotCount += 1
return .none
}
}


// в представлении
Text("Hello")
.task { await viewStore.send(.task).finish() } // автоматически завершается, когда вызывается «onDisappear»

С другой стороны, в TCA возвращаемое значение Task ловко оборачивается в новый тип TaskResult, аналогичный механизму Result. Благодаря этому ошибки обрабатываются в редьюсере без предыдущего метода их отлова.

Протокол «Reducer», написание редьюсеров с декларативным представлением

С версии 0.41.0 разработчики объявляют редьюсеры по новому протоколу «Reducer»  —  как показано в коде в инструменте тестирования  —  и с помощью Dependency вводят зависимости на разных уровнях редьюсеров.

Преимущества протокола «Reducer»:

  • Понятнее определение логики.

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

  • Удобнее поддержка IDE.

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

  • Аналогичный режим объявления для представлений SwiftUI.

Механизм компоновки редьюсера доработан с помощью конструктора результатов: редьюсеры теперь объявляются разработчиками так же, как представления SwiftUI, то есть лаконичнее и интуитивно понятнее. Настроен угол компоновки редьюсеров: способ возврата дочерних редьюсеров к родительским изменен на определение границ дочерних редьюсеров в родительских. Так понятнее, и устраняются типичные ошибки компоновки, вызванные неправильным порядком размещения родительских и дочерних редьюсеров.

  • Выше производительность редьюсера.

Новый метод объявления удобнее компилятору языка Swift для бо́льших оптимизаций производительности. На практике стек вызовов, созданный с применением протокола «Reducer» для того же экшена, заполнен меньше.

  • Совершеннее управление зависимостями.

Для объявления зависимостей используется новый метод DependencyKey, очень похожий на EnvironmentKey от SwiftUI. Аналогично EnvironmentValue им вводятся зависимости на уровнях редьюсера. В DependencyKey разработчики одновременно определяют реализации для интерактивных, тестовых и сценариев предварительного просмотра, чем еще проще становится настройка зависимостей в различных сценариях.

Примечания

Издержки освоения

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

Похоже, в TCA применяется подход к разработке «снизу вверх», однако добиться желаемого эффекта разработчикам будет невозможно, если не продумать полную функциональность.

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

Производительность

В TCA необходимо соответствие состояния и экшена протоколу «Equatable». Как и во многих Redux-подобных решениях, здесь нет поддержки состояний с ссылочным типом значения. То есть в некоторых сценариях, где необходимы ссылочные типы, для сохранения логики единого состояния нужно преобразовать ссылочные типы в типы значений, что чревато потерей производительности.

Кроме того, механизмом WithViewStore при работе с конкретными свойствами задействуется Combine. Когда уровней редьюсеров много, в TCA также велики затраты за разделение и сравнение. Стоит этим затратам превысить результат оптимизации, появятся проблемы с производительностью.

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

Как освоить TCA

Хотя с TCA серьезно снижается вероятность использования в представлениях других зависимостей, совместимых с протоколом «DynamicProperty», разработчикам необходимо хорошо понимать нативные решения SwiftUI для зависимостей. С одной стороны, во многих легковесных разработках такие тяжеловесные фреймворки не нужны. С другой  —  даже при использовании TCA разработчикам необходимо применять эти нативные зависимости как дополнение. Это полностью продемонстрировано в коде CaseStudies от TCA.

Новичкам в SwiftUI, мало знакомым с Redux и Elm, стоит начать с Redux-подобных фреймворков полегковеснее, а после этого режима разработки  —  изучать TCA.

В TCA имеется много примеров кода, от создания простейшего редьюсера до полнофункционального опубликованного приложения. Они продолжают меняться с обновлениями версий TCA, многие из которых доработаны с применением протокола «Reducer».

Самый новый и подробный контент о TCA с полными текстовыми версиями и кодом содержится в видеокурсах на бесплатном сайте Point Free. Так что, даже если плохо понимаете на слух, прочитаете текст.

Подписывайтесь по ссылке.

Заключение

Согласно плану, Combine с закрытым исходным кодом Apple скоро заменят в TCA на код async/await, сделав его фреймворком с поддержкой нескольких платформ. Возможно, в будущем TCA портируют на другие языки.

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи fatbobman ( 东坡肘子): The Composable Architecture (TCA)