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.
- iOS: 9.0 +
- macOS: 10.10 +
- watchOS 2.0 +
- tvOS: 9.0 +
Unicore is available through CocoaPods. To install it, simply add the following line to your Podfile:
pod 'Unicore', '~> 1.0.2'
The idea behind the Unicore is to have one single source of truth (app state) and make changes in a unidirectional manner.
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
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
)
- Set the generic parameter to
AppState
to letCore
knows that we need a reducer which deals withAppState
as a state. - Providing
Core
with the initial state - 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 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
.
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
}
}
- Unwrap
payload
if action isStepChangeRequested
- Return new instance of
AppState
counter
value stays the samestep
updates with the new value frompayload
- 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
}
}
- 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:
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.
let action = CounterIncreaseRequested() // #1
core.dispatch(action) // #2
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)
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
}
}
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)
Commands are the wrappers on swift closures with a convenient API to dispatch and bind them to values.
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)
}
When you have done with the command setup you execute it as a function with a particular value:
commandInt(with: 7)
PlainCommand
is a type alias for Command<Void>
it can be executed as a function plainCommand()
without a parameter.
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()
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.
TheMovieDB.org Client (Work In Progress)
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.
Unicore is available under the MIT license. See the LICENSE file for more info.