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 портируют на другие языки.
Читайте также:
- Как избежать повторных обновлений представлений SwiftUI
- Как инженеру-программисту Reactjs перейти на Swift и SwiftUI
- Как создать компонент Toast в SwiftUI
Читайте нас в Telegram, VK и Дзен
Перевод статьи fatbobman ( 东坡肘子): The Composable Architecture (TCA)