noxt / Unicore

The Unicore is a highly scalable application design approach which lets you increase the reliability of an application, increase testability, and give your team the flexibility by decoupling code of an application. It is a convenient combination of the data-driven and redux.js ideas.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Build Status Version License Platform

Unicore The Unicore

The Unicore is a highly scalable application design approach which lets you increase the reliability of an application, increase testability, and give your team the flexibility by decoupling code of an application. It is a convenient combination of the data-driven and Redux JS ideas.

The framework itself provides you with a convenient way to apply this approach to your app.

Requirements

  • iOS: 9.0 +
  • macOS: 10.10 +
  • watchOS 2.0 +
  • tvOS: 9.0 +

Installation

Unicore is available through CocoaPods. To install it, simply add the following line to your Podfile:

pod 'Unicore', '~> 1.0.2'

Design Approach

The idea behind the Unicore is to have one single source of truth (app state) and make changes in a unidirectional manner.

Unicore

App State

The app state would be that source it's a plain structure. For example simple structure like this:

struct AppState {
    let counter: Int
    let step: Int

    // Initial state
    static let initial = AppState(counter: 0, step: 1)
}

Let's imagine a simple app where we need to show the counter and increase/decrease buttons. And to add a bit of logic let's also have the control on a step.

So, in that case, the AppState would be our only source of the current app state, we look into the instance of the AppState and we have the right values we need to display. That's what is single source of truth.

But the problem here would be to give access to that state for each part of the app, screens, services, etc. and more importantly provide them with a way to mutate this state, so everybody knows that it was changed. The Observer pattern would solve these problems, but to make changes, we need some external ways not only internal as they usually are in the observer.

That's why we use the Event Bus pattern to solve this, and we dispatch actions to the Core which would mutate the state.

Core

Core is a dispatcher of the action, it uses the serial queue beneath so only one action gets handled at once that is why it is so important to not block a reducer function. Core is a generic type, so we can create it for any state we want.

let core = Core<AppState>( // #1
    state: AppState.initial, // #2
    reducer: reduce // #3
)
  1. Set the generic parameter to AppState to let Core knows that we need a reducer which deals with AppState as a state.
  2. Providing Core with the initial state
  3. Providing core with the reducer, the function we have written before.

When you dispatch an action to the core, it uses a Reducer to create a new version of the app state and then lets every subscriber know that there is a new app state.

Actions

Actions are also plain structures conforming to Action protocol.

The name of the action describes what has happened, and fields of the action (payload) describe the details of the event. For example this action:

struct StepChangeRequested: Action {
    let step: Int
}

means that step change was requested and the new step requested to be equal to field step.

Some actions might contain no fields and the only information they bring is the name of the action.

struct CounterIncreaseRequested: Action {}
struct CounterDecreaseRequested: Action {}

These actions give us information that an increase or decrease of the counter was requested.

Having current state and action we can get the new state using Reducer.

Reducer

A Reducer is a function which gets a state and action as a parameter and returns a new state, it's a pure function, and it must not block a current thread, that means every heavy calculation must be not in reducers.

And reducers are the only way to change the current state, for example, let's change the step if the action is StepChangeRequested then we update the step with the payload value:

func reduce(_ old: AppState, with action: Action) -> AppState {
    switch action {

    case let payload as StepChangeRequested: // #1
        return AppState( // # 2
            counter: old.counter, // #3
            step: payload.step // #4
        )

    default:
        return old // #5
    }
}
  1. Unwrap payload if action is StepChangeRequested
  2. Return new instance of AppState
  3. counter value stays the same
  4. step updates with the new value from payload
  5. for all other actions returns the old state

Test of this reducer might be something like this:

func testReducer_StepChangeRequestedAction_stepMustBeUpdated() {
    let sut = AppState(counter: 0, step: 1)
    let action = StepChangeRequested(step: 7)
    let new = reduce(sut, with: action)
    XCTAssertEqual(new.step, 7)
}

Let's also add handlers for an increase and decrease actions:

func reduce(_ old: AppState, with action: Action) -> AppState {
    switch action {

    case let payload as StepChangeRequested:
        return AppState(
            counter: old.counter,
            step: payload.step
        )

    case is CounterIncreaseRequested:
        return AppState(
            counter: old.counter + old.step, // #1
            step: old.step
        )

    case is CounterDecreaseRequested:
        return AppState(
            counter: old.counter - old.step,
            step: old.step
        )

    default:
        return old
    }
}
  1. We calculate a new value for counter

And tests would look something like this:

func testReducer_CounterIncreaseRequested_counterMustBeIncreased() {
    let sut = AppState(counter: 0, step: 3)
    let action = CounterIncreaseRequested()
    let new = reduce(sut, with: action)
    XCTAssertEqual(new.counter, 3)
}

func testReducer_CounterDecreaseRequested_counterMustBeDecreased() {
    let sut = AppState(counter: 0, step: 3)
    let action = CounterDecreaseRequested()
    let new = reduce(sut, with: action)
    XCTAssertEqual(new.counter, -3)
}

Alright, we prepare everything we need for making the application working, the only thing needed is to create our Core instance:

API and Usage

Create Core

To use Unicore you have to create a Core class instance. Since Core is a generic type, before that you have to define State class, it might be of any type you want. Let's say we have our state described as a structure App State, then you need to describe how this state is going to react to actions using a Reducer, now you good to go and you can create an instance of the Core:

let core = Core<AppState>(state: AppState.initial, reducer: reducer)

That's it we good to go, now we can dispatch an Action to modify our state or subscribe to state changes.

Dispatch

let action = CounterIncreaseRequested() // #1
core.dispatch(action) // #2

Subscribe

The only way to get the current state is to subscribe to the state changes, it's very important to know that you'll receive the current state value immediately when you subscribe:

core.observe { state in
    // do something with state
    print(state.counter)
}.dispose(on: disposer) // dispose the subscription when current disposer will dispose

The closure will be called whenever the state updates.

If you want to handle state updates on a particular thread, e.g. main thread to update your screen, you can use observe(on: DispatchQueue) syntax:

core.observe(on: .main) { (state) in
    self.counterLabel.text = String(state.counter)
}.dispose(on: disposer)

Dispose

When you subscribe to the state changes, the function observe returns a PlainCommand to remove the subscription when it's no longer needed. You can call it directly when you want to unsubscribe:

class YourClass {
    let unsubscribe: PlainCommand?

    func connect(to core: Core<AppState>) {
        unsubscribe = core.observe { (state) in
            // handle the state
        }
    }

    deinit {
        unsubscribe?()
    }
}

Or you can use a Disposer and add this command to it. A disposer will call this command when it will dispose:

class YourClass {
    let disposer = Disposer()

    func connect(to core: Core<AppState>) {
        core.observe { (state) in
            // handle the state
        }.dispose(on: disposer)
        // when YourClass will deinit hence disposer will also deinited, and before that, it will call all unsubscription functions registered in it
    }
}

Register Middleware

Middleware is supposed to help you to observe the state changes along with the action happened, and it might be useful when you want to track events to your analytics:

core.add(middleware: { (state, action) in
    if let payload = action as? ScreenShown {
        // if action is ScreenShown then track that screen has been shown
        // using screen name from action and application state at the moment
        tracker.trackScreenShown(payload.name, counter: state.counter)
    }
}).dispose(on: disposer)

Utilities

Command

Commands are the wrappers on swift closures with a convenient API to dispatch and bind them to values.

Initialization

Command is a generic type which uses Value as a type constraint Command<Int> would be equivalent to (Int) -> Void.

let commandInt = Command<Int>(action: { value in
    print(value)
})

or shorter the same

let commandInt = Command<Int> { value in
    print(value)
}

you can also specify a debug description values to have a hint when debugging

let commandInt = Command<Int>.init(id: "Print the int") { (value) in
    print(value)
}

Command Debug Preview

Execution

When you have done with the command setup you execute it as a function with a particular value:

commandInt(with: 7)

PlainCommand

PlainCommand is a type alias for Command<Void> it can be executed as a function plainCommand() without a parameter.

Binding to value

If you want to bind a comand with a value you can use convenient method .bound(to:) e.g.

let printInt = Command<Int>{ value in
  print(value)
}

let printSeven = printInt.bound(to: 7)

this will return a PlainCommand with 7 as a value, so when you will execute it, you no longer have to provide a value:

printSeven()

Dispatching

when you want the command to be executed only on a particular thread (queue), you can also set this by using async(on:) syntax

let printSevenOnMain = printSeven.async(on: .main)

now wherever you execute this command, it will be executed on the main thread.

Examples

Counter Example

TheMovieDB.org Client (Work In Progress)

Credits

Maxim Bazarov: A maintainer of the framework, and an evangelist of this approach.

Alexey Demedetskiy: An author of the original swift version and number of examples.

Redux JS: The original idea.

License

Unicore is available under the MIT license. See the LICENSE file for more info.

About

The Unicore is a highly scalable application design approach which lets you increase the reliability of an application, increase testability, and give your team the flexibility by decoupling code of an application. It is a convenient combination of the data-driven and redux.js ideas.

License:MIT License


Languages

Language:Swift 95.1%Language:Ruby 4.2%Language:Objective-C 0.7%