Please note, this is not an official Google repository. It is a Kotlin multiplatform experiment that makes no guarantees about API stability or long term support. None of the works presented here are production tested, and should not be taken as anything more than its face value.
Mutator is a Kotlin multiplatform library that provides a suite of tools that help with producing state while following unidirectional data flow (UDF) principles. More specifically it provides implementations of the paradigm newState = oldState + Δstate
.
Where StateProducer
s are defined as:
interface StateProducer<State : Any> {
val state: State
}
and Δstate
represents state changes over time and is expressed in Kotlin with the type:
typealias Mutation<State> = State.() -> State
At the moment, there are two implementations:
fun <State : Any> CoroutineScope.stateFlowProducer(
initialState: State,
started: SharingStarted,
mutationFlows: List<Flow<Mutation<State>>>
): StateProducer<StateFlow<State>>
and
fun <Action : Any, State : Any> CoroutineScope.actionStateFlowProducer(
initialState: State,
started: SharingStarted,
mutationFlows: List<Flow<Mutation<State>>>,
actionTransform: (Flow<Action>) -> Flow<Mutation<State>>
): StateProducer<StateFlow<State>>
stateFlowProducer
is well suited for MVVM style applications and actionStateFlowProducer
for MVI like approaches.
Both implementations enforce that coroutines launched in them are only active as specified by the SharingStarted
policies passed to them. For most UI StateProducer
s, this is typically SharingStarted.whileSubscribed(duration)
.
Any work launched that does not fit into this policy (a photo upload for example) should be queued to be run with the
appropriate API on the platform you're working on. On Android, this is WorkManager
.
implementation("com.tunjid.mutator:core:version")
implementation("com.tunjid.mutator:coroutines:version")
Where the latest version is indicated by the badge at the top of this file.
Please refer to the project website for an interactive walk through of the problem space this library operates in and visual examples.
CoroutineScope.stateFlowProducer
returns a class that allows for mutating an initial state over time, by providing a List
of Flows
that contribute to state changes. A simple example follows:
data class SnailState(
val progress: Float = 0f,
val speed: Speed = Speed.One,
val color: Color = Color.Blue,
val colors: List<Color> = MutedColors.colors(false).map(::Color)
)
class SnailStateHolder(
scope: CoroutineScope
) {
private val speed: Flow<Speed> = scope.speedFlow()
private val speedChanges: Flow<Mutation<Snail7State>> = speed
.map { mutation { copy(speed = it) } }
private val progressChanges: Flow<Mutation<Snail7State>> = speed
.toInterval()
.map { mutation { copy(progress = (progress + 1) % 100) } }
private val stateProducer = scope.stateFlowProducer(
initialState = Snail7State(),
started = SharingStarted.WhileSubscribed(),
mutationFlows = listOf(
speedChanges,
progressChanges,
)
)
val state: StateFlow<Snail7State> = stateProducer.state
fun setSnailColor(index: Int) = stateProducer.launch {
mutate { copy(color = colors[index]) }
}
fun setProgress(progress: Float) = stateProducer.launch {
mutate { copy(progress = progress) }
}
}
The actionStateFlowProducer
function transforms a Flow
of Action
into a Flow
of State
by first
mapping each Action
into a Mutation
of State
, and then reducing the Mutations
into an
initial state within the provided CoroutineScope
.
The above is typically achieved with the toMutationStream
extension function which allows for
the splitting of a source Action
stream, into individual streams of each Action
subtype. These
subtypes may then be transformed independently, for example, given a sealed class representative of
simple arithmetic actions:
sealed class Action {
abstract val value: Int
data class Add(override val value: Int) : Action()
data class Subtract(override val value: Int) : Action()
}
and a State
representative of the cumulative result of the application of those Actions
:
data class State(
val count: Int = 0
)
A StateFlow
Mutator
of the above can be created by:
val mutator = scope.actionStateFlowProducer<Action, State>(
initialState = State(),
started = SharingStarted.WhileSubscribed(),
transform = { actions ->
actions.toMutationStream {
when (val action = type()) {
is Action.Add -> action.flow
.map {
mutation { copy(count = count + value) }
}
is Action.Subtract -> action.flow
.map {
mutation { copy(count = count - value) }
}
}
}
}
)
Non trivially, given an application that fetches data for a query that can be sorted on demand. Its
State
and Action
may be defined by:
data class State(
val comparator: Comparator<Item>,
val items: List<Item> = listOf()
)
sealed class Action {
data class Fetch(val query: Query) : Action()
data class Sort(val comparator: Comparator<Item>) : Action()
}
In the above, fetching may need to be done consecutively, whereas only the most recently received
sorting request should be honored. A StateFlow
Mutator
for the above therefore may resemble:
val mutator = scope.actionStateFlowProducer<Action, State>(
initialState = State(comparator = defaultComparator),
started = SharingStarted.WhileSubscribed(),
transform = { actions ->
actions.toMutationStream {
when (val action = type()) {
is Action.Fetch -> action.flow
.map { fetch ->
val fetched = repository.get(fetch.query)
mutation {
copy(
items = (items + fetched).sortedWith(comparator),
)
}
}
is Action.Sort -> action.flow
.mapLatest { sort ->
mutation {
copy(
comparator = sort.comparator,
items = items.sortedWith(comparator)
)
}
}
}
}
}
)
In the above, by splitting the Action
Flow
into independent Flows
of it's subtypes,
Mutation
instances are easily generated that can be reduced into the current State
.
A more robust example can be seen in the Me project.
Sometimes when splitting an Action
into a Mutation
stream, the Action
type may need to be
split by it's super class and not it's actual class. Take the following Action
and State
pairing:
data class State(
val count: Double = 0.0
)
sealed class Action
sealed class IntAction: Action() {
abstract val value: Int
data class Add(override val value: Int) : IntAction()
data class Subtract(override val value: Int) : IntAction()
}
sealed class DoubleAction: Action() {
abstract val value: Double
data class Divide(override val value: Double) : DoubleAction()
data class Multiply(override val value: Double) : DoubleAction()
}
By default, all 4 Actions
will need to have their resulting Flows
defined. To help group them
into Flows
of their super types, a keySelector
can be used:
val actions = MutableSharedFlow<Action>()
actions
.toMutationStream(
keySelector = { action ->
when (action) {
is IntAction -> "IntAction"
is DoubleAction -> "DoubleAction"
}
}
) {
when (val type = type()) {
is IntAction -> type.flow
.map { it.mutation }
is DoubleAction -> type.flow
.map { it.mutation }
}
}
In the above the two distinct keys map to the IntAction
and DoubleAction
super types allowing
for granular control of the ensuing Mutation
stream.
Ultimately a Mutator
serves to produce a stream of State
from a stream of Actions
,
the implementation of which is completely open ended.
Copyright 2021 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.