Decorators = Plugins for Plugins
Decorators are currently experimental because their DSL is limited by Kotlin features and they are less safe/performant to use. There will be a breaking change with them within a few updates. They are also not tested enough as of 3.1 Beta releases.
Decorators are very similar to plugins, so the way they work may seem confusing at first.
In short,
A Decorator wraps another plugin and manages it manually.
If plugins are executed automatically as a Chain of Responsibility, Decorators instead decide whether to call
plugin methods themselves. If you return null
from a plugin callback, you can halt the chain, but you cannot
"wrap" it, watch over the entire chain of plugins, or skip the execution altogether. That's where you would use a
decorator.
Plugins can be used in two ways:
Decorating Plugins
A straightforward way is to create a decorator and call decorate
on a single plugin:
val plugin = plugin<State, Intent, Action> {
onIntent {
// does stuff
it
}
}
val decorator = decorator<State, Intent, Action> {
name = "FilterInvalidDecorator"
onIntent { chain, intent ->
if (intent is InvalidIntent) return@onIntent null
chain.run { onIntent(intent) } // returns the result of the chain
}
}
val decoratedPlugin = decorator decorates plugin
As you can see, there is an additional parameter chain
in the decorator callback. It is the plugin that we
wrap. Decorators can be applied to any plugin, so you can't know which exact plugin you are wrapping except by querying
its name
.
The chain
parameter is temporary and will become a receiver with a future Kotlin release. Right now,
calling the chain is awkward because you have to always wrap it in a run
block to bring the PipelineContext
into
the scope.
If you don't call the corresponding plugin method, it will be skipped entirely! This can result in very dangerous
behaviors if, for example, plugins initialize some resources in onStart
and expect to have them elsewhere.
Always auto-complete and use the chain
parameter in some way.
- The value you return from the decorator callback will behave in the opposite way to plugins. You should consider it
the "final" value, not the "next" or intermediate value that will go further down the chain.
A safe bet is to return from the decorator whatever the
chain
invocation returns. This means that you did not want to make modifications to the result. - If you don't define a particular decorator callback, it will be "transparent" (not skipped).
- You can decorate decorators because after you decorate a plugin, you get a plugin as a result.
Decorating Stores
This section makes my brain explode each time, but I will try to explain it as best as I can.
The interesting thing about FlowMVI is that Store is just a decorator. What it does is receive a plugin chain, wrapped in a Composite pattern after it is built, so that Store thinks it always only has a single plugin installed. It then, like a decorator, decides when and whether to call its plugin methods, and does things (sets states etc.) based on the value returned by the chain.
Previously I said that when you decorate a plugin, you get a plugin as a result. What this means is that
- Store is a Plugin of itself
- Store is a Decorator of itself
- Store decorates Decorators
- Decorators decorate Plugins
I know that makes 0 sense whatsoever. But practically, when you decorate a Store, you can think of it like this:
- First all of the installed plugins are merged into a single plugin.
- The first decorator you install decorates that chain.
- Then each one of your decorators decorates the previous ones in the order of installation.
- Then the Store decorates the last decorator in the chain and becomes a Plugin as a result.
So,
- Decorators are installed after all plugins, no matter where they are declared.
- Decorators are installed in order among themselves. The one declared lower wraps the upper one
- Whatever the last returned decorator returns from a callback becomes the final value passed to the Store.
Pre-made Decorators
The power of decorators enables some awesome features. You can take them as an example by examining their source code.
BatchIntentsDecorator
fun batchIntentsDecorator(
mode: BatchingMode,
queue: BatchQueue<I> = BatchQueue(),
name: String? = "BatchIntentsDecorator"
): PluginDecorator<S, I, A>
This one intercepts the intents coming through it and puts them in a queue. Based on the BatchingMode
it will either
accumulate a given Amount
of intents in the queue before flushing them as soon as the queue overflows, or
intercept all intents and flush them every Time
interval.
It is useful when you want to save some resources and want to do computations in bursts.
By default, it can only be installed once per Store.
Install with batchIntents(mode)
.
ConflateDecorator
fun <S : MVIState, I : MVIIntent, A : MVIAction> conflateIntentsDecorator(
name: String? = "ConflateIntents",
crossinline compare: ((it: I, other: I) -> Boolean) = MVIIntent::equals,
): PluginDecorator<S, I, A>
- Out of the box, Intents and Actions, unlike States, are not conflated. That means that if you send the same Intent
twice, it will trigger two rounds of processing. If you don't want that, you can install the decorator using
conflateIntents()
orconflateActions()
. - Using provided
compare
function, it will drop the second intent if the previous was the same as this (second) one, if the function returnstrue
.
It can be useful if you have a Store (Plugin) where the same intents/actions can be spammed a lot and you don't want them processed repeatedly.
IntentTimeoutDecorator
fun <S : MVIState, I : MVIIntent, A : MVIAction> intentTimeoutDecorator(
timeout: Duration,
name: String? = "IntentTimeout",
crossinline onTimeout: suspend PipelineContext<S, I, A>.(I) -> I? = { throw StoreTimeoutException(timeout) },
): PluginDecorator<S, I, A>
- This decorator will measure the time it takes to execute
onIntent
and (by default) throw an exception if processing takes longer than thetimeout
value. - When the
onTimeout
block is invoked, the execution has already been canceled, so you cannot continue it. It works with bothparallelIntents
and regular ones, but does not measure the time it takes to run a joblaunch
ed inside any of the chain links.
It can be useful when you want to prevent the Store from being stuck processing an Intent for long because of some heavy operation or a bug. In the block, you can resend the intent to retry, or report an error, for example.
RetryDecorator
Speaking of retry:
fun <S : MVIState, I : MVIIntent, A : MVIAction> retryIntentsDecorator(
strategy: RetryStrategy,
name: String? = null,
selector: (intent: I, e: Exception) -> Boolean = { _, _ -> true },
): StoreDecorator<S, I, A>
- This one will use the
selector
first to decide whether it should retry execution of theonIntent
callback, and if the block returnstrue
, it will retry processing it using the providedstrategy
. - If the
strategy
includes a delay of any kind, the decorator will move the processing to a separate coroutine to not prevent other intents from being processed. - You can use the following strategies:
RetryStrategy.ExponentialDelay
- each delay will be multiplied by the exponent. By default 2, 4, 8...RetryStrategy.FixedDelay
- each delay will be the same length up to a maxretries
.RetryStrategy.Once
- just retry once immediately without running asynchronously.RetryStrategy.Immediate
- retry immediately (while blocking other intents, ifparallelIntents
is not used), for up toretries
times.RetryStrategy.Infinite
- retry indefinitely and immediately until store is closed or succeeded. Very dangerous.
The word "Decorator" has been said 60 times in this document. Uhm, I meant, 61.