Mutator icon indicating copy to clipboard operation
Mutator copied to clipboard

Flow powered mutual exclusion for UI state mutation over time.

Mutator

JVM Tests Mutator Core Mutator Coroutines

badge badge badge badge badge badge badge badge

Android Weekly Feature

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.