Building Testable and Maintainable iOS Apps with Redux

Written by ze8c | Published 2024/01/08
Tech Story Tags: redux | swift | uikit | combine | ios | ios-app-development | clean-architecture | modularity

TLDRThis guide empowers developers to navigate Redux complexities, ensuring structured, scalable, and maintainable iOS apps and also core principles: a single source of truth, state immutability, and unidirectional data flow. Understanding Redux architecture enables robust and efficient app developmentvia the TL;DR App

Redux has gained immense popularity for managing application state in a predictable and maintainable manner. This comprehensive guide walks through the process of implementing Redux in an iOS app, empowering developers to build scalable and efficient applications. From understanding core principles to practical implementation, this guide serves as a robust resource for adopting Redux in iOS development

Understanding Redux Architecture: the core principles - single source of truth, state immutability, and unidirectional data flow, where:

  • Store: Define the single source of truth for the application state
  • Actions: Describe events that trigger state changes by reducer and trigger middleware
  • Reducers: Pure functions that handle actions and update the state
  • Middlewares: Interaction with services and side effects and the result of a new action

Setting Up the Project. Create a new iOS project. Initialize the project structure, including folders Core, Flow, MW, Store, Services, where:

  • Core for the basic architecture components and all additional general utilities
  • Flow for UI components Views and their Service something like Controller/Presenter/View Model
  • MW for Middlewares, DTO’s
  • Store for Actions, Reducers, State
  • Service for all components that are connected to the outside world something like File Manager/DB/Network/…

Defining Actions. At first make protocol for actions and at the same time include reducer. Define actions that represent events triggering state changes. Organize actions into separate files based on functionality.

Implementing Reducers. Create reducers that handle actions and update the state immutably. Combine reducers to manage different parts of the state

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)
        }
    }
}

Managing Asynchronous Operations. Make base elements and protocol for Middleware. Implement middleware to manage asynchronous actions. And create DTO with mapping to State model

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)
    }
}

Implementing the Store. Create State and Model wrapper for implementing all states. Create a store to manage the application state. Define the initial state and configure the store.

Of course Store implementation is very simple without corner cases

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:))
    }
}

Connecting Components to Redux. Integrate Redux with view components to read state from the store. Create simple View with ViewController and dispatch actions to trigger state updates.

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))
    }
}

And finally assembly all components together into 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()

All structural folders Core, Flow, MW, Store, Services you can separate into separate elements e.g. Frameworks/Swift Packages/Libraries or something like this

Conclusion:

In conclusion, the adoption of Redux architecture in iOS app development offers a structured, scalable, and efficient approach to managing application state. This comprehensive guide has illuminated the core principles of Redux, emphasizing the importance of a single source of truth, state immutability, and unidirectional data flow.

The step-by-step exploration of implementing Redux architecture has covered various key components


Written by ze8c | iOS Tech Lead
Published by HackerNoon on 2024/01/08