Сбалансированная архитектура мобильного приложения продлевает жизнь проекту и разработчикам.
История
Познакомьтесь с Алексом. Ему необходимо разработать приложение для составления списка покупок. Алекс опытный разработчик и первым делом формирует требования к продукту:
- Возможность портирования продукта на другие платформы (watchOS, macOS, tvOS)
- Полностью автоматизированный регресс приложения
- Поддержка iOS 13+
Недавно Алекс познакомился с проектом pointfree.co, где Брэндон и Стивен поделились своим видением современной архитектуры приложения. Так Алекс узнал о 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 строк кода на реализацию простого списка, позволяющего добавлять продукты и помечать приобретенные товары.
Смотри в следующей серии
Часть 3 – расширяем функционал, добавляем удаление и сортировку продуктов (in progress)
Часть 4 – добавляем кэширование списка и идем в магазин (in progress)
Источники
Список продуктов Часть 1: github.com
Портал авторов подхода: pointfree.co
Исходники Composable Architecture: https://github.com/pointfreeco/swift-composable-architecture