Mutator
Mutator copied to clipboard
Flow powered mutual exclusion for UI state mutation over time.
Mutator
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.
Introduction
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 StateProducers 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.
Foreground execution limits
Both implementations enforce that coroutines launched in them are only active as specified by the SharingStarted
policies passed to them. For most UI StateProducers, 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.
Download
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.
Examples and sample code
Please refer to the project website for an interactive walk through of the problem space this library operates in and visual examples.
CoroutineScope.stateFlowProducer
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) }
}
}
CoroutineScope.actionStateFlowProducer
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.
Nuanced use cases
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.
License
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.