Создание тестируемых и поддерживаемых приложений для iOS с помощью Redux

Создание тестируемых и поддерживаемых приложений для iOS с помощью Redux

9 января 2024 г.

Redux приобрел огромную популярность благодаря возможности предсказуемого и удобного в обслуживании управления состоянием приложения. В этом подробном руководстве описан процесс реализации Redux в приложении iOS, что дает разработчикам возможность создавать масштабируемые и эффективные приложения. Это руководство служит надежным ресурсом для внедрения Redux при разработке iOS: от понимания основных принципов до практической реализации.

Redux data flow

Понимание архитектуры Redux: основные принципы — единый источник достоверной информации, неизменность состояния и однонаправленный поток данных, где:

  • Хранилище: определите единый источник достоверной информации о состоянии приложения.
  • Действия: опишите события, которые вызывают изменения состояния с помощью редуктора и запускают промежуточное программное обеспечение.
  • Редукторы: чистые функции, которые обрабатывают действия и обновляют состояние.
  • Промежуточное ПО: взаимодействие со службами, побочные эффекты и результат нового действия.

Create a new iOS Project

Настройка проекта. Создайте новый проект iOS. Инициализируйте структуру проекта, включая папки Core, Flow, MW, Store, Services, где:

  • Ядро для основных компонентов архитектуры и всех дополнительных общих утилит.
  • Поток для представлений компонентов пользовательского интерфейса и их службы, например Controller/Presenter/View Model
  • MW для промежуточного программного обеспечения и DTO
  • Хранилище действий, редукторов и состояния
  • Сервис для всех компонентов, подключенных к внешнему миру, например File Manager/DB/Network/…

The Project Structure

Определение действий. Сначала составьте протокол действий и заодно включите редюсер. Определите действия, которые представляют события, вызывающие изменения состояния. Организуйте действия в отдельные файлы в зависимости от функциональности.

Реализация редукторов. Создайте редукторы, которые обрабатывают действия и неизменно обновляют состояние. Комбинируйте редукторы для управления различными частями состояния

protocol Action {
    associatedtype StateType: Equatable
    func reduce(_ state: inout StateType)
}

enum MainAction: Action {
    case user(UserAction)

    func reduce(_ state: inout MainState) {
        switch self {
        case let .user(action): action.reduce(&state)
        }
    }
}

enum UserAction: Action {
    case getUserData
    case receiveUser(UserModel)

    func reduce(_ state: inout MainState) {
        switch self {
        case .getUserData: state.user = .loading
        case let .receiveUser(model): state.user = .result(model)
        }
    }
}

Управление асинхронными операциями. Создайте базовые элементы и протокол для промежуточного программного обеспечения. Внедрите промежуточное программное обеспечение для управления асинхронными действиями. И создайте DTO с сопоставлением с моделью состояния

struct Box<Act> where Act: Action {
    let run: (@escaping (Act) -> Void) -> Void

    init(_ run: @escaping (@escaping (Act) -> Void) -> Void) {
        self.run = run
    }
}

extension Box {
    static func throwTask(
        _ run: @escaping (@escaping (Act) -> Void) async throws -> Void,
        catchError: @escaping (@escaping (Act) -> Void, Error) -> Void = { _, _ in }
    ) -> Self {
        Self { dispatcher in
            Task {
                do {
                    try await run(dispatcher)
                } catch {
                    catchError(dispatcher, error)
                }
            }
        }
    }
}

extension Box {
    static var zero: Self { Self { _ in }}

    static func +=(lhs: inout Self, rhs: Self) {
        let tmp = lhs
        lhs = Self { dispatcher in
            tmp.run(dispatcher)
            rhs.run(dispatcher)
        }
    }
}

protocol Middleware {
    associatedtype Act where Act: Action
    func run(state: Act.StateType, action: Act) -> Box<Act>
}

extension Middleware {
    var asAnyMiddleware: AnyMiddleware<Act> {
        AnyMiddleware(self.run(state:action:))
    }
}

struct AnyMiddleware<Act>: Middleware where Act: Action {
    private let runer: (Act.StateType, Act) -> Box<Act>

    init(_ run: @escaping (Act.StateType, Act) -> Box<Act>) {
        self.runer = run
    }

    func run(state: Act.StateType, action: Act) -> Box<Act> {
        runer(state, action)
    }
}

struct UserMW: Middleware {
    private let net: UserNetProvider

    init(net: UserNetProvider) {
        self.net = net
    }

    func run(state: MainState, action: MainAction) -> Box<MainAction> {
        switch action {
        case let .user(userAction): return handleUser(state: state.user, action: userAction)
        default: return .zero
        }
    }

    func handleUser(state: ModelWrapper<UserModel>, action: UserAction) -> Box<MainAction> {
        switch action {
        case .getUserData: return Box.throwTask { dispatcher in
            let userDTO: UserDTO = try await net.getUserData()
            dispatcher(.user(.receiveUser(userDTO.convert)))
        } catchError: { dispatcher, error in
            // Handle error
        }
        default: return .zero
        }
    }
}

struct UserDTO: Decodable {
    let name: String
    let age: Int

    var convert: UserModel {
        UserModel(name: name, age: age)
    }
}

Реализация Магазина. Создайте оболочку состояний и моделей для реализации всех состояний. Создайте хранилище для управления состоянием приложения. Определите исходное состояние и настройте хранилище.

Конечно, реализация Магазина очень проста и не требует особых случаев

enum ModelWrapper<Model> where Model: Equatable {
    case initialize
    case loading
    case error(Error)
    case result(Model)
}

extension ModelWrapper: Equatable {
    private var equatabeValue: String {
        return switch self {
        case .initialize: "initialize (String(describing: Model.self))"
        case .loading: "loading (String(describing: Model.self))"
        case let .error(error): error.localizedDescription
        case let .result(model): String(describing: model)
        }
    }

    static func == (lhs: ModelWrapper<Model>, rhs: ModelWrapper<Model>) -> Bool {
        lhs.equatabeValue == rhs.equatabeValue
    }
}

struct MainState: Equatable {
    public internal(set) var user: ModelWrapper<UserModel> = .initialize
}

struct UserModel: Equatable {
    let name: String
    let age: Int
}

actor Store<Act> where Act: Action {
    nonisolated var statePublisher: AnyPublisher<Act.StateType, Never> {
        stateSubject.eraseToAnyPublisher()
    }

    private var state: Act.StateType {
        didSet {
            stateSubject.send(state)
        }
    }
    private let stateSubject: CurrentValueSubject<Act.StateType, Never>

    private let middlewares: [AnyMiddleware<Act>]

    init(initState: Act.StateType, middlewares: [AnyMiddleware<Act>]) {
        self.state = initState
        self.stateSubject = CurrentValueSubject(initState)
        self.middlewares = middlewares
    }

    func dispatch(action: Act) {
        var box = Box<Act>.zero

        for mw in middlewares {
            box += mw.run(state: state, action: action)
        }

        action.reduce(&state)

        box.run(dispatch(action:))
    }
}

Подключение компонентов к Redux. Интегрируйте Redux с компонентами представления для чтения состояния из хранилища. Создайте простое представление с помощью ViewController и отправьте действия для запуска обновлений состояния.

class ReduxVC<Act>: UIViewController where Act: Action {
    private let store: Store<Act>
    private var box = Set<AnyCancellable>()

    init(store: Store<Act>) {
        self.store = store
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    func sink<S: Equatable>(map: KeyPath<Act.StateType, S>, _ sink: @escaping (_ storeState: S) -> Void) {
        store
            .statePublisher
            .map(map)
            .removeDuplicates()
            .receive(on: DispatchQueue.main)
            .sink(receiveValue: sink)
            .store(in: &box)
    }

    func dispatch(_ action: Act) {
        Task { await store.dispatch(action: action) }
    }
}

final class UserScreenView: UIView {

    private let spiner = {
        $0.isHidden = true
        $0.color = .black
        $0.translatesAutoresizingMaskIntoConstraints = false
        return $0
    }(UIActivityIndicatorView(style: .medium))

    private let dataView = {
        $0.isHidden = true
        $0.backgroundColor = .lightGray
        $0.clipsToBounds = true
        $0.layer.cornerRadius = 10
        $0.translatesAutoresizingMaskIntoConstraints = false
        return $0
    }(UIView())

    private let errorView = {
        $0.isHidden = true
        $0.backgroundColor = .lightGray
        $0.clipsToBounds = true
        $0.layer.cornerRadius = 10
        $0.translatesAutoresizingMaskIntoConstraints = false
        return $0
    }(UIView())

    private let nameLabel = {
        $0.font = .systemFont(ofSize: 22, weight: .regular)
        $0.textColor = .black
        $0.translatesAutoresizingMaskIntoConstraints = false
        return $0
    }(UILabel())

    private let ageLabel = {
        $0.font = .systemFont(ofSize: 22, weight: .regular)
        $0.textColor = .black
        $0.translatesAutoresizingMaskIntoConstraints = false
        return $0
    }(UILabel())

    private let errorLabel = {
        $0.font = .systemFont(ofSize: 22, weight: .regular)
        $0.textColor = .systemRed
        $0.translatesAutoresizingMaskIntoConstraints = false
        return $0
    }(UILabel())

    init() {
        super.init(frame: .zero)
        backgroundColor = .white

        addSubview(spiner)
        addSubview(dataView)
        dataView.addSubview(nameLabel)
        dataView.addSubview(ageLabel)
        addSubview(errorView)
        errorView.addSubview(errorLabel)

        NSLayoutConstraint.activate([
            spiner.centerXAnchor.constraint(equalTo: centerXAnchor),
            spiner.centerYAnchor.constraint(equalTo: centerYAnchor),
            spiner.heightAnchor.constraint(equalToConstant: 30),
            spiner.widthAnchor.constraint(equalToConstant: 30),

            dataView.centerXAnchor.constraint(equalTo: centerXAnchor),
            dataView.centerYAnchor.constraint(equalTo: centerYAnchor),
            nameLabel.leadingAnchor.constraint(equalTo: dataView.leadingAnchor, constant: 22),
            nameLabel.trailingAnchor.constraint(equalTo: dataView.trailingAnchor, constant: -22),
            nameLabel.topAnchor.constraint(equalTo: dataView.topAnchor, constant: 22),
            ageLabel.widthAnchor.constraint(equalTo: nameLabel.widthAnchor),
            ageLabel.centerXAnchor.constraint(equalTo: dataView.centerXAnchor),
            ageLabel.topAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 22),
            ageLabel.bottomAnchor.constraint(equalTo: dataView.bottomAnchor, constant: -22),

            errorView.centerXAnchor.constraint(equalTo: centerXAnchor),
            errorView.centerYAnchor.constraint(equalTo: centerYAnchor),
            errorLabel.leadingAnchor.constraint(equalTo: errorView.leadingAnchor, constant: 22),
            errorLabel.trailingAnchor.constraint(equalTo: errorView.trailingAnchor, constant: -22),
            errorLabel.topAnchor.constraint(equalTo: errorView.topAnchor, constant: 22),
            errorLabel.bottomAnchor.constraint(equalTo: errorView.bottomAnchor, constant: -22)
        ])
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    func loadingData() {
        dataView.isHidden = true
        errorView.isHidden = true
        spiner.isHidden = false
        spiner.startAnimating()
    }

    func errorSetup(_ error: String) {
        dataView.isHidden = true
        spiner.isHidden = true
        errorView.isHidden = false
        spiner.stopAnimating()

        errorLabel.text = error
    }

    func dataSetup(_ model: UserModel) {
        dataView.isHidden = false
        spiner.isHidden = true
        errorView.isHidden = true
        spiner.stopAnimating()

        nameLabel.text = model.name
        ageLabel.text = String(model.age)
    }
}

final class UserScreenVC: ReduxVC<MainAction> {

    let mainView = UserScreenView()

    override func loadView() {
        self.view = mainView
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        sink(map: .user) { [unowned self] state in
            switch state {
            case .initialize: break
            case .loading: mainView.loadingData()
            case let .error(err): mainView.errorSetup(err.localizedDescription)
            case let .result(model): mainView.dataSetup(model)
            }
        }

        dispatch(.user(.getUserData))
    }
}

И, наконец, соберите все компоненты вместе в SceneDelegate

guard let windowScene = (scene as? UIWindowScene) else { return }

let netProvider = NetProvider()

let store = Store<MainAction>(
    initState: MainState(),
    middlewares: [
        UserMW(net: UserNetProvider(netProvider: netProvider)).asAnyMiddleware
    ]
)

window = UIWindow(frame: UIScreen.main.bounds)
window?.windowScene = windowScene
window?.rootViewController = UserScreenVC(store: store)
window?.makeKeyAndVisible()

Все структурные папки Core, Flow, MW, Store, Services можно разделить на отдельные элементы, например. Фреймворки/Swift-пакеты/Библиотеки или что-то в этом роде

Вывод:

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

Пошаговое исследование реализации архитектуры Redux охватило различные ключевые компоненты


Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE