[Перевод] Redux — подобные контейнеры состояния в SwiftUI. Основы

image

На этой неделе мы поговорим о создании контейнера состояний схожий с тем, который используется в Redux. Он является единственным источником значений для разрабатываемого приложения. Единое состояние для всего приложения облегчает отладку и проверку. Единый источник истинностных значений устраняет тысячи ошибок, возникающих при создании нескольких состояний в приложении.

Единый источник истинностных значений


Основной идеей является описание состояния всего приложения посредством единой структуры или композиции структур. Допустим, что мы работаем над созданием приложения поиска Github репозиториев, где состояние — это массив репозиториев, который мы выбираем в соответствии с определенным запросом, используя Github API.

struct AppState {
    var searchResult: [Repo] = []
}

Следующим шагом является передача состояния (доступного только для чтения) каждому представлению внутри приложения. Наилучшим способом реализовать такую задачу является использование SwiftUI«s Environment. Также можно передать объект, содержащий состояние всего приложения, в Environment базового view. Базовое view поделится Environment со всеми child views. Чтобы узнать больше о функции SwiftUI«s Environment, ознакомьтесь с публикацией «Мощь Environment в SwiftUI».

final class Store: ObservableObject {
    @Published private(set) var state: AppState
}

В выше приведенном примере мы создали объект store (хранилище), который хранит состояние приложения и предоставляет к нему доступ только для чтения. Свойство State использует обертку свойства @Published, которая уведомляет SwiftUI о любых изменениях. Она позволяет постоянно обновлять все приложение, выводя его из единого источника истинностных значений. Ранее мы говорили о объектах хранилища в предыдущих статьях, чтобы узнать об этом больше, необходимо прочитать статью «Моделирование состояния приложения с использованием объектов Store в SwiftUI».

Reducer и Actions


Пришло время поговорить о действиях пользователя (actions), которые приводят к изменениям состояния. Action — это простое перечисление или совокупность перечислений, описывающих изменение состояния. Например, установите значение загрузки во время выборки данных, назначьте полученные репозитории свойству состояния и т.п. Теперь рассмотрим пример кода для перечисления Action.

enum AppAction {
    case search(query: String)
    case setSearchResult(repos: [Repo])
}

Reducer — это функция, которая принимает текущее состояние, применяет действие к состоянию и генерирует новое состояние. Обычно reducer или composition of reducers — это единственное место в приложение, в котором изменяется состояние. Тот факт, что единственная функция может изменять все состояние приложения, делает код очень простым, легко тестируемым и легко отлаживаемым. Ниже приведен пример функции reduce.

struct Reducer {
    let reduce: (inout State, Action) -> Void
}

let appReducer: Reducer = Reducer { state, action in
    switch action {
    case let .setSearchResults(repos):
        state.searchResult = repos
    }
}

Однонаправленный поток


Сейчас пришло время поговорить о потоке данных. Каждое представление имеет доступ только для чтения к состоянию через объект store. Представления могут отправлять actions в объект хранилища. Reducer изменяет состояние, а затем SwiftUI уведомляет все представления об изменениях состояния. SwiftUI имеет суперэффективный алгоритм сравнения, поэтому отображение состояния всего приложения и обновление измененных представлений работает очень быстро.
State → View → Action → State → View

Такая архитектура работает только вокруг однонаправленного потока данных. Это означает, что все данные в приложении следуют одному и тому же шаблону, что делает логику создаваемого приложения более предсказуемой и более легкой для понимания. Давайте изменим store объект для поддержки отправки actions.

final class Store: ObservableObject {
    @Published private(set) var state: State

    private let appReducer: Reducer

    init(initialState: State, appReducer: @escaping Reducer) {
        self.state = initialState
        self.appReducer = appReducer
    }

    func send(_ action: Action) {
        appReducer.reduce(&state, action)
    }
}

Сайд эффекты


Мы уже реализовали однонаправленный (unidirectional) поток, который принимает действия пользователя и изменяет состояние, но как насчет асинхронного действия (async action), которое мы обычно называем side effects. Как добавить поддержку асинхронной задачи для используемого типа хранилища? Думаю, что пришло время представить использование Combine Framework, который идеально подходит для обработки асинхронных задач.

import Foundation
import Combine

protocol Effect {
    associatedtype Action
    func mapToAction() -> AnyPublisher
}

enum SideEffect: Effect {
    case search(query: String)

    func mapToAction() -> AnyPublisher {
        switch self {
        case let .search(query):
            return dependencies.githubService
                .searchPublisher(matching: query)
                .replaceError(with: [])
                .map { AppAction.setSearchResults(repos: $0) }
                .eraseToAnyPublisher()
        }
    }
}

Мы добавили поддержку async tasks (асинхронных задач), введя Effect протокол. Effect — это последовательность Actions, которую можно опубликовать, используя тип Publisher из Combine framework«s. Это позволяет обрабатывать асинхронные задания с помощью Combine, а затем публиковать actions, которые будут использоваться reducer для применения actions к текущему состоянию.

final class Store: ObservableObject {
    @Published private(set) var state: State

    private let appReducer: Reducer
    private var cancellables: Set = []

    init(initialState: State, appReducer: Reducer) {
        self.state = initialState
        self.appReducer = appReducer
    }

    func send(_ action: Action) {
        appReducer.reduce(&state, action)
    }

    func send(_ effect: E) where E.Action == Action {
        effect
            .mapToAction()
            .receive(on: DispatchQueue.main)
            .sink(receiveValue: send)
            .store(in: &cancellables)
    }
}

Пример практического использования


Наконец, мы можем завершить приложение для поиска репозиториев, которое асинхронно вызывает Github API и выбирает репозитории, соответствующие запросу. Полный исходный код приложения доступен на Github.

struct SearchContainerView: View {
    @EnvironmentObject var store: Store
    @State private var query: String = "Swift"

    var body: some View {
        SearchView(
            query: $query,
            repos: store.state.searchResult,
            onCommit: fetch
        ).onAppear(perform: fetch)
    }

    private func fetch() {
        store.send(SideEffect.search(query: query))
    }
}

struct SearchView : View {
    @Binding var query: String
    let repos: [Repo]
    let onCommit: () -> Void

    var body: some View {
        NavigationView {
            List {
                TextField("Type something", text: $query, onCommit: onCommit)

                if repos.isEmpty {
                    Text("Loading...")
                } else {
                    ForEach(repos) { repo in
                        RepoRow(repo: repo)
                    }
                }
            }.navigationBarTitle(Text("Search"))
        }
    }
}

Разделим экран на два представления: Container View и Rendering View. Container View управляет действиями и выполняет выборку необходимых частей из global state. Rendering View принимает данные и отображает их. Мы уже говорили о Container Views в предыдущих статьях, чтобы узнать больше, перейдите по ссылке «Introducing Container views in SwiftUI»

Выводы


Сегодня мы узнали, как создать Redux-подобный контейнер состояния с учетом side-effects. Для этого мы использовали функцию SwiftUI Environment и фреймворк Combine. Я надеюсь, данная статья была полезной.

Спасибо за прочтение, и до встречи!

© Habrahabr.ru