The Unicore
Architecture | Installation | Framework API | FAQ | Examples | Credits
iOS: 9.0 + | macOS: 10.10 + | watchOS 2.0 + | tvOS: 9.0 +
The Unicore is a highly scalable application design approach (architecture) which lets you increase the maintainability 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 components and a unidirectional dataflow.
The framework itself provides you the convenient way to manage an app state. It's Core
on the picture below.
- Reducer is a pure function
(State, Action) -> State
- Core is a Redux like
State
keeper which allows you todispatch
Action
s to it andobserve
theState
changes - Connector is a pure function
(State) -> Props
, whereProps
is a structure whatComponent
s need to do their job - Component is a
Props
consumer (Screens, API Services, Core Location Managers, etc...) which provides side-effects
Architecture
Data-driven components
TBD:
Unidirectional dataflow
The idea behind the Unicore is to have one single source of truth (app state) and make changes in a unidirectional manner.
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
)
- 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
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
}
}
- 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:
Installation
Unicore is available through CocoaPods and Carthage. To install it, simply add
Cocoapods
the following line to your Podfile:
pod 'Unicore', '~> 1.0.2'
Carthage
or the following line to your Cartfile:
github "Unicore/Unicore"
API and Usage
Action
Core
observe(on:with:)
dispatch(_ action:)
onAction(execute:)
Disposer
andDisposable
dispose(on:)
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)
observe(on:with:)
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)
dispatch(_ action:)
let action = CounterIncreaseRequested() // #1
core.dispatch(action) // #2
onAction(execute:)
Subscribes to observe Actions and the old State before the change when action has happened. Recommended using only for debugging purposes.
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
}
}
Examples
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.