Сбалансированная архитектура мобильного приложения продлевает жизнь проекту и разработчикам.
История
Познакомьтесь с Алексом. Ему необходимо разработать приложение для составления списка покупок. Алекс опытный разработчик и первым делом формирует требования к продукту:
- Возможность портирования продукта на другие платформы (watchOS, macOS, tvOS)
- Полностью автоматизированный регресс приложения
- Поддержка iOS 13+
Недавно Алекс познакомился с проектом pointfree.com, где Брэндон и Стивен поделились своим ведением современной архитектуры приложения. Так Алекс узнал о Composable Architecutre.
Composable Architecture
Изучив документацию к Composable Architecture, Алекс определил, что имеет дело с однонаправленной архитектурой, соответствующей требованиям к проекту. Из брошюры следовало:
- Разбиение проекта на модули;
- Data-driven UI — конфигурация интерфейса определяется его состоянием;
- Вся логика модуля покрывается юнит тестами;
- Snapshot тестирование интерфейсов;
- Поддержка iOS 13+, macOS, tvOS и watchOS;
- Поддержка SwiftUI и UIKit.
Перед тем, как погружаться в изучение архитектуры, давайте посмотрим на такой объект, как умный зонтик.
Как описать систему, по которой устроен зонтик?
У системы зонтика можно выделить четыре компонента:
Состояние. У зонтика есть два состояния: свернут и открыт.
Действия. Зонтик можно открыть и закрыть.
Механизм. Автоматический зонтик открывается и закрывается с помощью встроенного механизма.
Сервисы. Умный зонтик отправляет уведомление на телефон при удалении от него на 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/
Добавить комментарий