Composable Architecture — свежий взгляд на архитектуру приложения

от автора

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

История

Познакомьтесь с Алексом. Ему необходимо разработать приложение для составления списка покупок. Алекс опытный разработчик и первым делом формирует требования к продукту:

  1. Возможность портирования продукта на другие платформы (watchOS, macOS, tvOS)
  2. Полностью автоматизированный регресс приложения
  3. Поддержка iOS 13+

Недавно Алекс познакомился с проектом pointfree.com, где Брэндон и Стивен поделились своим ведением современной архитектуры приложения. Так Алекс узнал о Composable Architecutre.

Composable Architecture

Изучив документацию к Composable Architecture, Алекс определил, что имеет дело с однонаправленной архитектурой, соответствующей требованиям к проекту. Из брошюры следовало:

  1. Разбиение проекта на модули;
  2. Data-driven UI — конфигурация интерфейса определяется его состоянием;
  3. Вся логика модуля покрывается юнит тестами;
  4. Snapshot тестирование интерфейсов;
  5. Поддержка iOS 13+, macOS, tvOS и watchOS;
  6. Поддержка SwiftUI и UIKit.

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

image alt

Как описать систему, по которой устроен зонтик?

У системы зонтика можно выделить четыре компонента:

Состояние. У зонтика есть два состояния: свернут и открыт.

Действия. Зонтик можно открыть и закрыть.

Механизм. Автоматический зонтик открывается и закрывается с помощью встроенного механизма.

Сервисы. Умный зонтик отправляет уведомление на телефон при удалении от него на 10 метров.

Таким же образом в composable architecture описывается экран или вью. Предлагаю взглянуть на схему.

Ты еще помнишь как работает зонтик? Давай посмотрим, как бы это было в боевых терминах.

UI — пользователь [зонтика];

Action — набор допустимых действий;

State — состояние [зонтика];

Environment — набор внешних сервисов [сервис взаимодействия с телефоном];

Reducer — механизм, выполняющий работу по изменению состояния [зонтика] и порождающий эффекты;

Effect — задача, по завершению которой возвращается action в reducer.

Список продуктов (Часть 1)

Определение компонентов системы

Вооружившись новыми знаниями Алекс приступил к написанию кода к проектированию главного экрана приложения.

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

struct ShoppingListState {     var products: [Product] = [] }  enum ShoppingListAction {     case addProduct }

Тогда reducer для такой системы будет выглядеть следующим образом:

let shoppingListReducer = Reducer { state, action, env in     switch action {     case .addProduct:         state.products.insert(Product(), at: 0)         return .none     } }

По аналогии опишем компоненты системы для элемента списка:

struct Product {     var id = UUID()     var name = ""     var isInBox = false }  enum ProductAction {     case toggleStatus     case updateName(String) }  let productReducer = Reducer { state, action, env in     switch action {     case .toggleStatus:         state.isInBox.toggle()         return .none     case .updateName(let newName):         state.name = newName         return .none     } }

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

Описав и протестировав систему можно приступить к верстке UI и сбору отдельных компонентов системы.

Верстка UI

С учетом требований к поддержке iOS 13+ и полной совместимости Composable Architecture со SwiftUI, будем использовать его для верстки интерфейса приложения.

Для того, чтобы объединить компоненты в систему необходимо создать Store:

typealias ShoppingListStore = Store<ShoppingListState, ShoppingListAction>  let store = ShoppingListStore(     initialState: ShoppingListState(products: []),     reducer: shoppingListReducer,     environment: ShoppingListEnviroment() )

Store по своему поведению похож на viewModel из MVVM — передается и хранится во вью.

let view = ShoppingListView(store: store)  struct ShoppingListView: View {     let store: ShoppingListStore      var body: some View {         Text("Hello, World!")     } }

Composable Architecture предоставляет несколько полезных инструментов для работы со SwiftUI. Для того, чтобы использовать store как ObservedObject, его стоит обернуть в WithViewStore:

var body: some View {     WithViewStore(store) { viewStore in         NavigationView {             Text("\(viewStore.products.count)")             .navigationTitle("Shopping list")             .navigationBarItems(                 trailing: Button("Add item") {                     viewStore.send(.addProduct)                 }             )         }         .navigationViewStyle(StackNavigationViewStyle())     } }

На этом этапе у нас есть кнопка Add item, которая увеличивает количество продуктов в списке. События отправляются в редьюсер через метод send(Action) у стора.

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

struct ProductView: View {     let store: ProductStore      var body: some View {         WithViewStore(store) { viewStore in             HStack {                 Button(action: { viewStore.send(.toggleStatus) }) {                     Image(                         systemName: viewStore.isInBox                             ? "checkmark.square"                             : "square"                     )                 }                 .buttonStyle(PlainButtonStyle())                 TextField(                     "New item",                     text: viewStore.binding(                         get: \.name,                         send: ProductAction.updateName                     )                 )             }             .foregroundColor(viewStore.isInBox ? .gray : nil)         }     } }

Композиция

У нас есть две независимые системы и их представления. Как же их соеденить? В дело вступает черная магия композиция.

enum ShoppingListAction {         // Добавляем поддержку событий для продукта по индексу     case productAction(Int, ProductAction)     case addProduct }  // Соеденям два механизма друг с другом // т.к. редьюсер это функция, редьсеры можно комбинировать let shoppingListReducer: Reducer<ShoppingListState, ShoppingListAction, ShoppingListEnviroment> = .combine(         // Добавляем редьюсеры, обрабатывающие события для каждого продукта     productReducer.forEach(                 // Key path         state: ShoppingListState.products,                 // Case path         action: /ShoppingListAction.productAction,         environment: { _ in ProductEnviroment() }     ),     Reducer { state, action, env in         switch action {         case .addProduct:             state.products.insert(Product(), at: 0)             return .none                 // Все текущие действия обрабатываются в productReducer         case .productAction:             return .none         }     } )

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

Осталось обновить UI для отображения списка продуктов:

var body: some View {     WithViewStore(store) { viewStore in         NavigationView {             List {         // для каждого продукта                 ForEachStore(             // создаем store                     store.scope(                         state: \.products,                         action: ShoppingListAction.productAction                     ),             // создаем вью                     content: ProductView.init                 )             }             .navigationTitle("Shopping list")             .navigationBarItems(                 trailing: Button("Add item") {                     viewStore.send(.addProduct)                 }             )         }         .navigationViewStyle(StackNavigationViewStyle())     } }

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

Смотри в следующей серии

Часть 2 — покрываем написанное тестами (in progress)

Часть 3 — расширяем функционал, добавляем удаление и сортировку продуктов (in progress)

Часть 4 — добавляем кэширование списка и идем в магазин (in progress)

Источники

Список продуктов Часть 1: github.com

Портал авторов подхода: pointfree.com

Исходники Composable Architecture: https://github.com/pointfreeco/swift-composable-architecture

ссылка на оригинал статьи https://habr.com/ru/post/526782/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *