Pull to refresh

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

Reading time5 min
Views19K

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


История


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


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

Недавно Алекс познакомился с проектом pointfree.co, где Брэндон и Стивен поделились своим видением современной архитектуры приложения. Так Алекс узнал о 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 – пишем тесты


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


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


Источники


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


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


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

Tags:
Hubs:
Total votes 8: ↑8 and ↓0+8
Comments6

Articles