Создание тестируемых и поддерживаемых приложений для iOS с помощью Redux
9 января 2024 г.Redux приобрел огромную популярность благодаря возможности предсказуемого и удобного в обслуживании управления состоянием приложения. В этом подробном руководстве описан процесс реализации Redux в приложении iOS, что дает разработчикам возможность создавать масштабируемые и эффективные приложения. Это руководство служит надежным ресурсом для внедрения Redux при разработке iOS: от понимания основных принципов до практической реализации.
Понимание архитектуры Redux: основные принципы — единый источник достоверной информации, неизменность состояния и однонаправленный поток данных, где:
- Хранилище: определите единый источник достоверной информации о состоянии приложения.
- Действия: опишите события, которые вызывают изменения состояния с помощью редуктора и запускают промежуточное программное обеспечение.
- Редукторы: чистые функции, которые обрабатывают действия и обновляют состояние.
- Промежуточное ПО: взаимодействие со службами, побочные эффекты и результат нового действия.
Настройка проекта. Создайте новый проект iOS. Инициализируйте структуру проекта, включая папки Core, Flow, MW, Store, Services, где:
- Ядро для основных компонентов архитектуры и всех дополнительных общих утилит.
- Поток для представлений компонентов пользовательского интерфейса и их службы, например Controller/Presenter/View Model
- MW для промежуточного программного обеспечения и DTO
- Хранилище действий, редукторов и состояния
- Сервис для всех компонентов, подключенных к внешнему миру, например File Manager/DB/Network/…
Определение действий. Сначала составьте протокол действий и заодно включите редюсер. Определите действия, которые представляют события, вызывающие изменения состояния. Организуйте действия в отдельные файлы в зависимости от функциональности.
Реализация редукторов. Создайте редукторы, которые обрабатывают действия и неизменно обновляют состояние. Комбинируйте редукторы для управления различными частями состояния
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 охватило различные ключевые компоненты
Оригинал