Uni-directional data flow & finite state machine merged together.
Every app has an architecture, good or bad. Since there is no universal methodology about how to build an app, every developer/team has to come up with their own solution every time an app is being built.
There are several fundamental challenges that every app has to solve, it doesn't matter what this app is about:
Optionally, there are few more fundamental challenges that every app faces sooner or later (not everyone makes it a priority, but it becomes more-or-less necessary at some point of time during evolution of the project):
So let's define app architecture as a set of rules that define how listed above challenges are being solved in particular app.
There are quite few design patterns that are trying to describe how to organize overall application structure on a high level (MVC, MVVM, etc.). They are not very specific and different developers interpret and implement these patterns in a slightly different way.
One of the most promising (and relatively new on iOS) is so-called "unidirectional data flow" pattern introduced by Facebook in their Flux framework. The most well established native implementation of this pattern for Apple platforms written in Swift is ReSwift.
It's a very powerful framework that seems to cover all the fundamental needs. However, there are several things that are not so great and might be improved.
The subscription mechanism requires:
Middleware seems to be absolutely overkill/unnecessary complication, even a simple example looks super complicated.
A framework like this should be a tool that helps and inspires to:
The goal of this framework is to provide a tool for defining and managing application state and delivering notifications about such state mutations to subscribed observers. This library provides the highest level of abstraction in application development process, so any kind of specific tasks (like networking, data encoding/decoding, any kind of computations, GUI configuration, etc.) are out of scope.
Any app consists of two main components: model (static component, which provides storage for all possible kinds of data that the app can operate with) and business logic (dynamic component, which represents all possible mutations that might happen with that data model).
On the other hand, computer program (app) is a State Machine. This, in particular, means, that to write an app we have to define all possible app states and all possible transitions between these states which we wish to allow.
Moreover, each app consists of features, which may or may not depend one on another. Every feature may require to store some data to operate with, to represent internal state, to deliver some computation results, etc. The exact set of required kinds of data, as well as data values may change over time.
To sum it up, every app should be represented as a set of features. Every feature can be defined by one or several alternative states (every feature state corresponds to its own model), plus transitions between these states.
App model - global model - is a special container object that at any moment of time contains all currently initialized features. In turn, every initialized feature at any given moment of time is represented by exactly one of its states. Obviously, each feature state that is currently presented in global model defines the current state of corresponding feature; if no single state of a given feature is currently presented in the global model, then current state of that particular feature is undefined (the feature is not being used currently).
This concludes data model of an app.
App business logic can be represented by transitions between different feature states. That means each transition should change current state of one or several features. In general case, each transition consists of pre-conditions which must be fulfilled before this transition can be performed, as well as transition body that defines how exactly this transition is going to be made. Transitions are also used to bring any kind of input from outer world into the app (for example, user input, system notifications, etc.)
The recommended way is to install using SwiftPM, but Carthage is also supported out of the box..
Each app feature should be represented by a data type that conforms to Feature
protocol. Its name corresponds to the feature name. This data type is never supposed to be instantiated and will be needed as meta data for corresponding feature states only.
Each of the app feature states should be represented by a data type that conforms to FeatureState
protocol and explicitly defines corresponding feature via typealias UFLFeature
. Instances of these data types will be used to represent their features.
All app features are supposed to be stored in a single global storage represented by data type called GlobalModel
. Each app supposed to have the only intance of that type. It is a single point of truth at any moment of time, which stores global app state. On a high level, it works much like a dictionary, where app features are used as keys, and corresponding feature states are stored as values. This means that GlobalModel
may or may not contain any given feature at any given moment of time, but if it contains a feature - it only contains one and only one particular feature state; as soon as we decide to to put another feature state into GlobalModel
(after we made a transition) - it will override any previously saved feature state (for this particular feature) that was stored in GlobalModel
at the moment.
Each transition should be represented by an instance of Action
, a special data type (struct
) that contains transition name and body (in the form of closure
).
There is a special technique for how to define transition. Action
initializer is inaccessible directly. It is supposed that all transitions should be defined in form of static functions that return Action
instance. Such functions must be encapsulated into special data type that conforms to ActionContext
protocol: this protocol provides exclusive access to a special static function that allows to create Action
instance by passing into it transition body. Such technique enforces source code unification and provides great flexibility: the encapsulating function can accept any number of input parameters, that can be captured into transition body closure, but in the end transition body is always just a closure with no input parameters.
In most cases, it is recommended to encapsulate state transitions into related features, so Feature
protocol inherits ActionContext
protocol.
After we have defined app features, their states and transitions, we need to make it work together. Each app has to maintain one and only one dispatcher - instance of Dispatcher
class. It's is recommended to create and start using one first thing after app finishes launching.
Dispatcher has several responsibilities:
GlobalModel
);Action
data type that mutate the GlobalModel
instance stored inside dispatcher);Import framework like this:
import XCEUniFlow
First of all, you need to create a dispatcher. The recommended way is just to decalre an internal instance level constant in your AppDelegate
class. This guarantees that dispatcher has the same life cycle as the app itself.
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate
{
// ...
let dispatcher = Dispatcher()
// ...
}
For each data type that will need access to global app state, it is recommended to implement DispatcherInitializable
or DispatcherBindable
protocol. These two protocols implement Dependency Injection and unify how potential observers are being connected with dispatcher.
Here is example of custom UIWindow-subclass that implements DispatcherInitializable
protocol.
final
class Window: UIWindow, DispatcherInitializable
{
required
convenience
init(with proxy: DispatcherProxy)
{
self.init(frame: UIScreen.main.bounds)
//---
// here subscribe for updates from dispatcher via proxy, if needed
// store proxy internally, if needed
}
}
Here is example of custom UIWindow-subclass that implements DispatcherBindable
protocol.
final
class Window: UIWindow, DispatcherBindable
{
func bind(with proxy: DispatcherProxy) -> Window
{
// here subscribe for updates from dispatcher via proxy, if needed
// store proxy internally, if needed
}
}
It's responsibility of the observer to subscribe or does not subscribe for updates from dispatcher, when provided access to dispatcher proxy. Moreover, given that the observer is going to initiate global state mutations (pass into the app user input, system notifications, etc.) or may need proxy later during app execution outside of the dispatcher updates, it is also a good idea to store proxy internally for future use, because that proxy is the only recommended way to submit actions to dispatcher.
Here is example of custom UIViewController-subclass that implements DispatcherInitializable
protocol. It does not subscribe for dispatcher notifications, but stores proxy
for future use independent from dispatcher notifications.
// lets say we have a custom view, subclass of UIView,
// which also accepts proxy during initialization
final
class View: UIView, DispatcherInitializable
{
// instance of this view will be created by the 'Ctrl' class defined below
// ...
}
// ...
final
class Ctrl: UIViewController, DispatcherInitializable
{
private(set)
var proxy: DispatcherProxy!
//---
required
convenience
init(with proxy: DispatcherProxy)
{
self.init(nibName: nil, bundle: nil)
//---
//...
self.proxy = proxy // save proxy for future use
}
// ...
override
func loadView()
{
// we have no guarantee when this method will be called,
// and when it's called - we need to have the proxy available to pass it further
view = View(with: proxy)
}
}
To subscribe for notifications from dispatcher, all you need to do is, basically, register an object as observer and provide corresponding update handler
. Optionally you may also provide convert handler
which is responsible for converting global app state into more specific model (this helps make the code even more declarative).
In most cases, to subscribe an observer for notifications from dispatcher you need to implement one of the two mentioned earlier protocols (DispatcherInitializable
or DispatcherBindable
). When got access to proxy
- just pass self
as observer and a custom closure/function that accepts global app state as input parameter into onUpdate
function.
Here is an example of how a custom UIView-based class subscribes for dispatcher notifications. Note, that in this example the onUpdate
function accepts another function as input parameter - for the sake of better code organization.
final
class View: UIView, DispatcherInitializable
{
// ...
required
convenience
init(with proxy: DispatcherProxy)
{
self.init(frame: CGRect.zero)
//---
// ...
//---
proxy
.subscribe(self)
.onUpdate(configure)
}
// ...
func configure(with model: GlobalModel)
{
// here use model to re-configure self as needed
}
}
Optionally, you may want to pass a custom closure/function that accepts global app state and returns any kind of custom or system data type ("sub-state") into onConvert
function, and then pass custom closure/function that accepts sub-state as input parameter into onUpdate
function. See example below.
final
class View: UIView, DispatcherInitializable
{
// ...
required
convenience
init(with proxy: DispatcherProxy)
{
self.init(frame: CGRect.zero)
//---
// ...
//---
proxy
.subscribe(self)
.onConvert(prepare)
.onUpdate(configure)
}
// ...
func prepare(from globalModel: GlobalModel) -> Int?
{
var result: Int = nil
// if possible, convert globalModel somehow into local model,
// in this example local model represented by "Int"
return result
}
func configure(with localModel: Int)
{
// here use localModel to re-configure self as needed
}
}
Note, that observer object works like a key in a dictionary to identify subscription among all other subscriptions. Only one subscription is possible per observer. Every submit attempt to setup a subscription for an observer will override previous subscription for this observer.
One of the most important techniques in this methodology is how to define features, feature states and state transitions.
Lets model a simple search feature.
Assume we have a simple GUI where user have a single input text field where a search keyword must be entered (it might be a single or multi-word string, it doesn't matter).
When user finishes input and starts search process, the input text field is no longer editable, the search keyword can not be changed anymore. In the background the app is doing search for the given keyword.
When the search is finished, we have on hands an array of items as result of search (might be empty), as well as the search keyword (read-only) for which these results have been found.
To define an app feature, lets declare a custom data type that conforms to Feature
protocol. Feature data type is not supposed to be instantiated, so it's a good idea to use enum
data type to declare app features.
enum Search: Feature
{
// ...
}
Inside Search
type, lets declare 3 nested types, they will represent corresponding Search
states.
enum Search: Feature
{
struct Preparing: SimpleState { typealias UFLFeature = Search
// getting user input, waiting for start
}
struct InProgress: FeatureState { typealias UFLFeature = Search
// the search process for a given keyword is in progress
}
struct Finished: FeatureState { typealias UFLFeature = Search
// the search process for a given keyword is finished,
// got list of results (may be empty)
}
}
Note, that SimpleState
is a special protocol inherited from FeatureState
. All it does is gives the library a hint, that the type that conforms to that protocol can be instantiated without parameters (using default system-provided init
constructor). That protocol is recommended for states that do not have internal variables or they have default values. We will see how it is used later.
Now lets extend each feature state with necessary constants and variables that reflects the essence of corresponding state.
In the beginning, until user finished input and started the search process, Search
feature is supposed to be represented by Preparing
state. We do not need to store in model anything in that state.
When user finished input and started the actual search process, and until the search process has been finished, Search
feature supposed to be represented by InProgress
state. While search is in progress, we may need to know what is the keyword for which we are doing search right now. So lets add a constant (!) that will store search keyword inside InProgress
state.
struct InProgress: FeatureState { typealias UFLFeature = Search
// the search process for a given keyword is in progress
let keyword: String // read-only, requires to set value explicitly
}
When the search process has been finished, Search
feature automatically transitions into Finished
state. Here we still need to know what is the keyword for which we have completed search process just now, as well as represent a list of results (as we do not know what's the data type of results list elements, let it be Any
, it doesn't matter for the purpose of this example).
struct Finished: FeatureState { typealias UFLFeature = Search
// the search process for a given keyword is finished,
// got list of results (may be empty)
let keyword: String // read-only, requires to set value explicitly
let results: [Any] // read-only, requires to set value explicitly
}
Now lets connect these states together by defining transitions.
First of all, lets define transition that initializes the feature.
extension Search
{
static
func setup() -> Action
{
return initialization(into: Preparing.self)
}
}
In the example above, a special helper static function initialization
(provided by the library) has been used. It automates many routine checks and operations. This specific helper works with one specific feature state, makes a transition where initial state is undefined and target state is as provided (Preparing
in our case). This particular function works only with feature states that conform to SimpleState
protocol. Under the hood it makes all the necessary checks for you - ensures that the feature is NOT presented in global state yet, and then, if everything is good, creates an instance of target state and puts it into global model, or fails action processing otherwise. More on this and other special helpers later.
submit, when user finished input and initiates search process, we need to transition from Preparing
state into InProgress
state. Here is an example of how it might be implemented.
static
func begin(with word: String) -> Action
{
return transition(from: Preparing.self, into: InProgress.self) { _, become, submit in
become { InProgress(keyword: word) }
//---
var list: [Any] = []
// do the search here, on background thread most likely
// when search is finished - return to main thread and
// deliver results by submitting another action via 'submit' handler
// ...
submit { finished(with: word, results: list) }
}
}
In the example above, a special helper static function transition
(provided by the library) has been used. It automates many routine checks and operations. This specific helper makes a transition between the two provided states of the same feature. Under the hood it makes all the necessary checks for you - ensures that the feature IS already presented in global state and its current state is as provided (Preparing
in this case), and then, if everything is good, lets you create an instance of target state to later put it into global model for you, or fails action processing otherwise. More on this and other special helpers later.
Finally, when the search is finished, we need to transition from InProgress
state into Finished
state. Here is an example of how it might be implemented.
static
func finished(with word: String, results list: [Any]) -> Action
{
return transition(from: InProgress.self, into: Finished.self) { _, become, _ in
become { Finished(keyword: word, results: list) }
}
}
To initiate transition processing, submit corresponding Action
(with necessary parameters, if any) to dispatcher via its proxy
(see example below).
let proxy = // get proxy from dispatcher
proxy.submit { Search.setup() } // initialize feature in global model
// ... wait for user input and initiation of actual search process...
let word = // get input from user
proxy.submit { Search.begin(with: word) } // actually start search process
// ...
Remember, all actions are being processed serially on the main thread, one-by-one, in the same order as they have been submitted (FIFO).
Above is an example of bare minimum that might be needed to solve a task like this. The example might be extended with a dedicated state for failure (that also may store the error occurred) on some other states, depending on specifics of particular search. Also there should be a transition that discards search results and prepares for a new search, deinitialization transition (in case the search view is closed completely and we do not need to keep in memory anything related to Search
feature at all). And so on.
There are quite a few positive outcomes from using this framework as a foundation for your app:
The project has evolved through several minor and 5 major updates. Current notation considered to be stable and pretty well balanced in terms of ease of use, concise and self-expressive API and functionality. Pretty much any kind of functionality can be implemented using proposed methodology.
link |
Stars: 20 |
Last commit: 2 days ago |
Got rid of helpers for pre-verification and error throwing. The recommended replacement is the corresponding helpers from ‘MKHRequirement’ framework.
Swiftpack is being maintained by Petr Pavlik | @ptrpavlik | @swiftpackco | API | Analytics