Rx is indeed a powerful tool, but it is also a double-edged sword. If abused, it will bring side effects. In this article, how to better manage the application state and logic after the introduction of Rx mode has made some superficial summary.

This paper is quite long and mainly focuses on the topic of state management. The first two parts introduce the state management mode adopted by React and Vue in the front-end domain and its implementation in Swift. At last, another simplified state management scheme is introduced. There will be no complex Rx features involved, just some basic understanding of Rx before reading.

Why is state management so important

A complex page usually needs to maintain a large number of variables to represent the various states during its running. In MVVM, most of the state and logic of the page are maintained through the ViewModel. In common writing, the ViewModel and view are usually communicated with a Delegate. For example, notify the view layer to update the UI when data changes, etc. :

In this mode, state updates to the ViewModel require us to call the Delegate to manually notify the view layer. In Rx, this relationship is diluted. Because Rx is responsive, the ViewModel only needs to change the value of the data after setting the binding relationship, and Rx automatically notifies each observer:

Rx hides the process of notifying the view. First, the benefits are obvious: The ViewModel can focus more on the data itself, without having to worry about the LOGIC of the UI layer. However, overusing this feature can cause trouble. The large number of observable variables and binding operations can make the logic ambiguous, and changing a variable can lead to an unpredictable chain reaction that makes the code harder to maintain.

To make the transition to responsive programming, a unified state management scheme is essential. There are a lot of mature practices in this front-end area, and there are some open source libraries in Swift to implement it. We can refer to the ideas in them first.

Example code for the following introduction is available at: github.com/L-Zephyr/My… .

Redux – ReSwift

Redux is a state management model developed by Facebook based on Flux and implemented by an open source project in Swift called ReSwift.

Bidirectional and unidirectional binding

To understand Redux, you need to understand what problems Redux was created to solve. Redux provides unified state management for applications and enables one-way data flow. The so-called one-way binding and two-way binding describe the relationship between views and models:

For example, if we have a message display page, we need to load the latest message from the network. In MVC, we can write:

class NormalMessageViewController: UIViewController {
 	var msgList: [MsgItem] = [] / / the data source
    
    // Network request
    func request(a) {
        // 1. Play the loading animation before the request starts
        self.startLoading()
        
        MessageProvider.request(.news) { (result) in
            switch result {
            case .success(let response):
                if let list = try? response.map([MsgItem].self) {
                    // 2. Update the model after the request ends
                    self.msgList = list
                }
            case .failure(_) :break
            }
            
            // 3. Update UI after model update
            self.stopLoading()
            self.tableView.reloadData()
        }
    }
    // ...
}
Copy the code

You can also remove unwanted messages from the list:

extension NormalMessageViewController: UITableViewDataSource {
	func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) {
        if editingStyle == .delete {
            // update the model
            self.msgList.remove(at: indexPath.row)
            
            // 2. Refresh UI
            self.tableView.reloadData()
        }
    }
    // ...
}
Copy the code

In the request method, we modify the data msgList through the network. Once the msgList changes, the UI must be refreshed. Obviously, the state of the view is synchronized with the data. When deleting a message on the tableView, the view layer works directly on the data and then refreshes the UI. The view layer responds to events that change data, and accesses and modifies data directly, which is a bidirectional binding relationship:

While it may seem simple in this example, when the page is more complex, mixing UI operations with data operations can mess up the logic. The View layer cannot modify the data directly. It can only pass events to the data layer through some mechanism and refresh the UI when the data changes.

implementation

To construct a one-way data flow, Redux introduces a set of concepts, which are described in Redux as data flows:

State is the State of the application, which is also our Model part. Regardless of the concepts such as Action and Reducer, it can be seen from the figure that State and View have a direct binding relationship. View events will indirectly change the State through a series of operations such as Action and Store. The following is a detailed introduction to the realization of Redux data flow and the concepts involved:

  1. As the name implies, a View is a View, and user actions on the View do not directly modify the model, but are mapped to individual actions.

  2. Action An Action represents a request for a data operation that is sent to the Store. This is the only way to modify the model data.

    In ReSwift there is a protocol called Action (an empty protocol for marking only) that requires an Action for each Action on Model data, such as setting a value:

    // set the Action for the data
    struct ActionSetMessage: Action {
        var news: [MsgItem] = []}/// Remove an Action
    struct ActionRemoveMessage: Action {
        var index: Int
    }
    Copy the code

    An Action is represented by a struct type, and the data carried by the Action is stored in its member variables.

  3. Store and State As mentioned above, State represents the Model data in the app, and Store is where State is stored; In Redux, the Store is a global container in which all component states are stored; The Store accepts an Action, then modifies the data and notifies the view layer to update the UI.

    As shown below, each page and component has its own state and Store for that state:

    // State
    struct ReduxMessageState: StateType {
        var newsList: [MsgItem] = []}// Store. Use the Store type of ReSwift to initialize the reducer and state
    let newsStore = Store<ReduxMessageState>(reducer: reduxMessageReducer, state: nil)
    Copy the code

    The Store receives the Action via a dispatch method that the view calls to pass the Action to the Store:

    messageStore.dispatch(ActionRemoveMessage(index: 0))
    Copy the code
  4. Reducer Reducer is a special function. In fact, we draw on some ideas of functions. First, Redux emphasizes Immutable data, which simply means that a data Model cannot be modified after creation. The answer is to create a new Model, which is what Reducer does:

    Reducer is a function with the following signature:

    (_ action: Action._ state: StateType?). ->StateType
    Copy the code

    Take an action representing an action and a state representing the current state, then calculate and return a new state, which is then updated to the Store:

    // Store. Swift
    open func _defaultDispatch(action: Action) {
        guard! isDispatchingelse {
            raiseFatalError("...")
        }
        isDispatching = true
        let newState = reducer(action, state) // 1. Calculate the new state using the reducer
        isDispatching = false
    
        state = newState // 2. Assign the new state directly to the current state
    }
    Copy the code

    All data model update operations in the application are finally completed by Reducer. In order to ensure the normal completion of this process, Reducer must be a pure function: its output depends only on input parameters and does not depend on any external variables, nor can it contain any asynchronous operations.

    In this example Reducer is written like this:

    func reduxMessageReducer(action: Action, state: ReduxMessageState?) -> ReduxMessageState {
        var state = state ?? ReduxMessageState(a)// Modify the data accordingly for different actions
        switch action {
        case let setMessage as ActionSetMessage: // Set the list data
            state.newsList = setMessage.news
        case let remove as ActionRemoveMessage: // Remove an item
            state.newsList.remove(at: remove.index)
        default:
            break
        }
        // Return directly to the entire modified State structure
        return state
    }
    Copy the code

Finally, implement the StoreSubscriber protocol in the view to receive notification of State changes and update the UI. See the Redux folder in Demo for detailed code.

Analysis of the

Redux divides the View -> Model relationship into View -> Action -> Store -> Model. Each module is responsible for only one thing, and data is always transmitted one-way along this link.

  • Advantages:

    • One-way data flows are easier to maintain when dealing with a large number of states, all events are manually triggered through a single entry dispatch, and each processing of the data is transparent so that every state change operation can be tracked. In the front end, Redux-DevTools, a companion tool for Redux, provides a feature called Time Travel that can trace any historical state of an application.

    • The global Store facilitates sharing state between multiple components.

  • Disadvantages:

    • First, Redux specifies a large number of rules for its data flow, which will undoubtedly lead to higher learning costs.

    • In the core model of Redux, async is not considered (Reducer is a pure function), so asynchronous tasks such as network requests need to be handled indirectly through mechanisms such as ActionCreator, which further increases the complexity.

    • Another commonly criticized shortcoming is that Redux introduces a lot of boilerplate code. In this simple example we need to create different structures for the page, such as Store, State, Reducer, and Action:

      Even something as simple as changing a state variable has to go through this process, which can add a lot of code.

To sum up, although the Redux model has many advantages, its costs cannot be ignored. Consider Redux if your pages and interactions are extremely complex or if there is a lot of shared state between multiple pages, but for most applications, the Redux model is not a good fit.

Vuex – ReactorKit

Vue is also one of the most popular front-end frameworks in recent years. Vuex is a state management mode specially proposed for Vue, which is optimized on Redux. ReactorKit is an open source library for Swift, and some of its design concepts are very similar to Vuex’s, so I’ll put them together here.

implementation

Unlike ReSwift, the ReactorKit implementation itself is easy to base on RxSwift, so you don’t have to worry about how to combine with Rx. Here is a flow chart of the data in ReactorKit:

The general process is similar to Redux, except that the Store is changed into a Reactor, which is a new concept introduced by ReactorKit. Instead of uniformly managing state globally, each component manages its own state, so each view component has its own Reactor.

Please refer to the ReactorKit folder in Demo for the specific code. The meanings of each part are as follows:

  1. Reactor:

    Now to rewrite the above example using the ReactorKit, we first need to create a MessageReactor type for this page that implements the Reactor protocol:

    class MessageReactor: Reactor {
        // Same as Action in Redux, it can be asynchronous
        enum Action {
            case request
            case removeItem(Int)}// Indicates the action to modify the state (synchronization)
        enum Mutation {
            case setMessageList([MsgItem])
            case removeItem(Int)}/ / state
        struct State {
            var newsList: [MsgItem] = []}... }Copy the code

    A Reactor needs to define State, Action and Mutation, which will be introduced one by one later.

    First of all, there is an additional Mutation concept compared with Redux. In Redux, actions can only be used to represent synchronous operations because they directly correspond to the operations in Reducer. ReactorKit further refined the concept, splitting it into two parts: Action and Mutation:

    • Action: Actions triggered by the view layer, which can represent both synchronous and asynchronous (such as network requests), will eventually be takenConverted to MutationThen it is passed to Reducer;
    • Mutation: can only represent a synchronous operation, which is equivalent to the Action in Redux mode. It is finally passed into Reducer to participate in the calculation of the new state.
  2. Mutate () :

    Mutate () is a method in the Reactor that converts user-triggered actions to Mutation. Mutate () allows actions to represent asynchronous operations, since both asynchronous and synchronous actions will eventually be converted to Mutation:

    func mutate(action: MessageReactor.Action) -> Observable<MessageReactor.Mutation> {
        switch action {
        case .request:
            // 1. Async: after the network request ends, the data obtained is transformed into Mutation
            return service.request().map { Mutation.setMessageList($0)}case .removeItem(let index):
            // 2. Sync: wrap a Mutation directly with just
            return .just(Mutation.removeItem(index))
        }
    }
    Copy the code

    It’s worth noting that the mutate() method returns an instance of the Observable

    type. Thanks to Rx’s descriptive power, synchronous and asynchronous code can be handled in a consistent manner.

  3. Reduce () :

    The reduce() method plays the same role as Reducer in Redux. The only difference is that it accepts a Mutation type, but the essence is the same:

    func reduce(state: MessageReactor.State, mutation: MessageReactor.Mutation) -> MessageReactor.State {
        var state = state
    
        switch mutation {
        case .setMessageList(let news):
            state.newsList = news
        case .removeItem(let index):
            state.newsList.remove(at: index)
        }
    
        return state
    }
    Copy the code
  4. Service

    There is also a Service object that interacts with Mutate (). Service refers to the place where specific business logic is implemented. Reactor implements specific business logic through various Service objects, such as network requests:

    protocol MessageServiceType {
        /// Network request
        func request(a) -> Observable"[MsgItem]>
    }
    
    final class MessageService: MessageServiceType {
        func request(a) -> Observable"[MsgItem] > {return MessageProvider
            	.rx
            	.request(.news)
            	.mapModel([MsgItem].self)
            	.asObservable()
        }
    }
    Copy the code

    The essence of the Reactor is pretty clear here: the Reactor is really an intermediate layer that manages the state of the view and acts as a communication bridge between the view and the concrete business logic.

In addition, ReactorKit expects all of our code to be written in the functional Response (FRP) style, which can be seen from the API design: The Reactor type does not provide methods such as Dispatch, but only provides a Subject variable action:

var action: ActionSubject<Action> { get }
Copy the code

In Rx, Subject is both observer and observable, often acting as a bridge between the two. All actions on a view are bound to the Action variable via Rx, rather than manually triggered: let’s say we want to make a network request on viewDidLoad, which is normally written like this:

override func viewDidLoad(a) {
    super.viewDidLoad()
    service.request() // Manually triggers a network request action
}
Copy the code

ReactorKit’s preferred functional style is this:

// Bind is the place for unified event binding
func bind(reactor: MessageReactor) {
    self.rx.viewDidLoad // 1. ViewDidLoad as an observable event
        .map { Reactor.Action.request } // 2. Convert the viewDidLoad event to Action
        .bind(to: reactor.action) // 3. Bind to action variable
        .disposed(by: self.disposeBag)
    // ...
}
Copy the code

The bind method is where the view layer does the event binding. We take VC viewDidLoad as an event source, convert it to the network request Action and bind it to reactor.action. This event source will emit an event and trigger the network request action in the Reactor when VC’s viewDidLoad is called.

This is more FRP, everything is a flow of events, but the actual use is not so perfect. First we need to provide Rx extensions for all UI components we use (the above example uses the library RxViewController); The bind method is called automatically at the time the REACTOR instance is initialized, so it cannot be initialized in viewDidLoad, otherwise the viewDidLoad event will be missed.

Analysis of the

  • advantages:
    • Compared to ReSwift, it simplifies some processes and manages individual state on a component basis, which is easier to introduce in existing projects.
    • withRxSwfitWell combined together, can provide a more complete functional response (FRP) development experience;
  • disadvantages:
    • Because the core idea is Redux, the problem of too much template code is unavoidable.

Another simplification

The Redux pattern is still too heavy for most applications, and the Language features of Swift are not as flexible as JavaScript, so a lot of boilerplate code is unavoidable. So here is another set of simplified solutions, hoping to enjoy the advantages of one-way data flow while reducing the burden of users.

See the Custom folder in the Demo for more code:

The implementation is very simple, and the core is a Store type:

public protocol StateType {}public class Store<ConcreteState> :StoreType where ConcreteState: StateType {
    public typealias State = ConcreteState

    /// state variable, a read-only variable
    public private(set) var state: State
    
    /// The state variable corresponds to the observable, when the state changes' rxState 'will send the corresponding event
    public var rxState: Observable<State> {
        return _state.asObservable()
    }
    
    /// Force the status update, and all observers will receive the next event
    public func forceUpdateState(a) {
        _state.onNext(state)
    }
    
    // update the status variables in a closure. When the closure returns, all updates are applied at once to update the status variables
    public func performStateUpdate(_ updater: (inout State) -> Void) {
        updater(&self.state)
        forceUpdateState()
    }
    ...
}
Copy the code

Where StateType is an empty protocol and is used only as a type constraint. As a base class, Store is responsible for storing the state of the component and managing the data source for state updates. The core code is very simple. Let’s take a look at the actual application.

ViewModel

In real development, I let the ViewModel handle the logic of state management and change. To implement the above example, I split the ViewModel of a business side into three parts:

/ / < 1 >
struct MessageState: StateType {... }/ / < 2 >
extension Reactive where Base: MessageViewModel {... }/ / < 3 >
class MessageViewModel: Store<MessageState> {
    required public init(state: MessageState) {
        super.init(state: state)
    }
    ...
}
Copy the code

The meanings of each part are as follows:

  • Define the state variables for the page

    All state variables needed to describe a page need to be defined in a separate struct that implements the StateType protocol:

    struct MessageState: StateType {
        var msgList: [MsgItem] = [] // Raw data
    }
    Copy the code

    As you can see from the previous code, the Store has a read-only state property:

    public private(set) var state: State
    Copy the code

    The business ViewModel accesses the current state variable directly through self.state. Modifying the state variable is done using a performStateUpdate method signed as follows:

    public func performStateUpdate(_ updater: (inout State) -> Void)
    Copy the code

    ViewModel changes state variables directly using parameters in the updater closure:

    performStateUpdate { $0.msgList = [...] } // Modify the status variable
    Copy the code

    The page state is updated after execution, and the bound UI component receives the status update event. This avoids creating an Action for each state variable, simplifies the process, and all status update operations go through the same entry, facilitating subsequent analysis.

    Unified management of state variables has the following advantages:

    • * Logical: * Just look at this type when browsing the code of the page to know which variables need special attention;
    • * Page persistence: * Just serialize the structure to hold all information about the page, and on recovery only assign the deserialized State toViewModelthestateVariables:self.state = localState;
    • * Easy to test: * Unit testing can be done by checking variables of type State;
  • Define exposed observable variables (getters)

    The ViewModel needs to expose some observables that the view can bind to. The Store provides an Observable

    object named rxState as a unified event source for status updates. However, in order to facilitate the use of the view layer, We need to refine it further.

    This part of the logic is defined in the Rx extension of the ViewModel, which provides observable properties that define all the states that the view layer needs to bind. This section acts as a Getter and is the interface for the view layer to get the data source from the ViewModel:

    extension Reactive where Base: MessageViewModel {
        var sections: Observable"[MessageTableSectionModel] > {return base
            	.rxState // Streams from the unified event source rxState
                .map({ (state) -> [MessageTableSectionModel] in
                    // Convert the back-end raw model type in the VM to a viewmodel that the UI layer can use directly
                    return [
                        MessageTableSectionModel(items: state.msgList.map { MessageTableCellModel.news($0})]})}}Copy the code

    In this way, the view layer does not need to care about the data types in State, and can directly obtain the attributes it needs to observe through the RX attribute:

    // The view layer watches sections directly, without caring about the internal transformation logic
    vm.rx.sections.subscribe(...)
    Copy the code

    Why define the interface used by the view layer in the extension instead of looking directly at rxState in the base class:

    • Variables defined in the Rx extension can be accessed directly through the Rx property of the ViewModel for easy use by the view layer;
    • The raw data in State may require a certainconversionTo be used by the view layer (such as the original aboveMsgItemType conversion into TableView can be directly used by the SectionModel model), this part of the logic is suitable to be placed in the extended calculation properties, so that the view layer is more pure;
  • Externally provided methods (Actions)

    The ViewModel also needs to receive view-layer events to trigger specific business logic, which, if done via Rx binding, imposes restrictions on how business layer code can be written (see ReactorKit above). Therefore, this part does not do too much encapsulation, but still exposes the interface in the form of methods. This part is equivalent to Action, but the cost is that Action can no longer be distributed through a unified interface:

    class MessageViewModel: Store<MessageState> { 
        / / request
        func request(a) {
            state.loadingState = .loading
            MessageProvider.rx
                .request(.news)
                .map([MsgItem].self)
                .subscribe(onSuccess: { (items) in
                    // The UI layer automatically responds when the request is completed by changing the variables in state
                    self.performStateUpdate {
                        $0.msgList = items
                        $0.loadingState = .normal
                    }
                }, onError: { error in
                    self.performStateUpdate { $0.loadingState = .normal }
                })
                .disposed(by: self.disposeBag)
        }
    }
    Copy the code

    We’ve completely separated the state from the UI, so the ViewModel logic only cares about the state in the state, not the interaction with the view layer, so the code written this way is also pretty clean.

View

The View layer needs to implement a protocol called View, which mainly refers to the design in ReactorKit:

/// View layer protocol
public protocol View: class {
    // declare the type of the ViewModel corresponding to the view
    associatedtype ViewModel: StoreType
    
    /// An instance of ViewModel, with a default implementation, the view layer needs to be initialized at the appropriate time
    var viewModel: ViewModel? { set get }
    
    /// The view layer implements this method and binds it
    func doBinding(_ vm: ViewModel)
}
Copy the code

For the view layer, it needs to do two things:

  • Implement a doBinding method where all Rx event binding is done:

    func doBinding(_ vm: MessageViewModel) {
        vm.rx.sections
            .drive(self.tableView.rx.items(dataSource: dataSource))
            .disposed(by: self.disposeBag)   
    }
    Copy the code
  • Initialize viewModel properties when appropriate:

    override func viewDidLoad(a) {
        super.viewDidLoad()
        // Initialize the ViewModel
        self.viewModel = MessageViewModel(state: MessageState()}Copy the code

    The doBinding method is automatically called when the viewModel is initialized, and is executed only once during the lifetime of the instance.

The binding of various states is a very important link in the View layer. The significance of View protocol is to standardize the binding of events in the View layer and prevent the code of binding operation from scattering around to reduce readability.

The data flow

The page data flow is as follows:

  1. When an event in a View fires, the corresponding method is invoked directlyViewModelLogic in;
  2. ViewModelTo execute the specific business logic and passperformStateUpdateModify the State variables stored in State.
  3. When state variables change, Rx binding automatically notifies the view layer to update the UI;

This ensures that data on a page always changes as expected, and the nature of one-way data flow allows us to track all state changes like Redux. For example, we can simply use Swift’s Mirror to print all state changes to the console:

public func performStateUpdate(_ updater: (inout State) -> Void) {
    updater(&self.state)

    #if DEBUG
    StateChangeRecorder.shared.record(state, on: self) // Record state changes
    #endif

    forceUpdateState()
}
Copy the code

Implementation of the code in StateChangeRecorder. Swift file, fewer than 100 lines is very simple. A Log is printed in the console whenever a state change occurs:

If you implement serialization and deserialization for all StateType types, you can even implement Time Travel functions like Redux-DevTools, which I won’t cover here.

conclusion

The introduction of Rx mode requires many aspects of consideration. This paper only introduces state management. The three schemes introduced above have their own characteristics, and the final choice should be judged based on the actual situation of the project.