Container is a library for simplifying state management and data loading in android applications.
Just add the following line to your build.gradle file:
implementation "com.elveum:container:2.0.0-beta10"
Click here.
Container type representing the status of async/load operation:
Container.Pending- data is loadingContainer.Completed- data loading is finished:Container.Success- data has been loaded successfullyContainer.Error- loading has been failed with error
Both Container.Success and Container.Error extends Container.Completed.
You can create containers by using the following functions:
successContainer(value)errorContainer(exception)pendingContainer()
Reducers make state management very easy to implement in your app. The main difference
from MutableStateFlow is the possibility to update values from multiple
sources: from input Kotlin Flows, along with manual updates if needed.
Reducers can be created by using:
- extension functions on any Kotlin Flow, such as
Flow<T>.toReducer() - combine functions, e.g.
combineToReducer
These functions works very similar to stateIn and/or shareIn operators, since
they accept a CoroutineScope instance and SharingStarted strategy. And at the same
time, the returned type (Reducer) gives you an update() call, in contrast to
stateIn operator, which returns non-mutable StateFlow.
So, any existing Kotlin Flow can be converted into Reducer:
interface GetItemsUseCase {
operator fun invoke(): Flow<List<String>>
}
@HiltViewModel
class MyViewModel @Inject constructor(
private val getItems: GetItemsUseCase,
) : ViewModel() {
data class State(
val items: List<String> = emptyList(),
)
private val reducer = getItems() // Flow<List<String>>
.toReducer(
initialState = State(),
nextState = State::copy,
scope = viewModelScope,
started = SharingStarted.Lazily,
) // Reducer<State>
val stateFlow: StateFlow<State> = reducer.stateFlow
}You can even simplify the creation of any reducer if you enable Kotlin Context Parameters feature. For example:
-
Modify your
build.gradlefile:kotlin { compilerOptions { freeCompilerArgs.add("-Xcontext-parameters") } } -
Create an abstract ViewModel implementing
ReducerOwnerinterface:abstract class AbstractViewModel : ViewModel(), ReducerOwner { override val reducerCoroutineScope: CoroutineScope get() = viewModelScope override val reducerSharingStarted: SharingStarted get() = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000) }
-
Now, you can create reducers without specifying
CoroutineScopeandSharingStartedvalues in child view-models:class MyViewModel : AbstractViewModel() { // <-- extend abstract view-model private val reducer: Reducer<State> = getItems() .toReducer( initialState = State(), nextState = State::copy, ) val stateFlow: StateFlow<State> = reducer.stateFlow }
As a result, you have a public stateFlow: StateFlow<State> property, which is
automatically updated any time when a new value comes from the Flow returned by
GetItemsUseCase. Additionally, you can update state manually:
reducer.update { oldState ->
oldState.copy(items = emptyList())
}Another one feature is the possibility to split your state class into public interface and private implementation without mappings. For example, let's add an additional property which defines how string items must be displayed:
// private state:
private data class StateImpl(
val originItems: List<String> = emptyList(),
// when enabled, all strings must be uppercased:
val isUppercase: Boolean = false,
)Next, define a public state interface:
interface State {
val items: List<String>
}Implement the interface in your private state:
private data class StateImpl(
val originItems: List<String> = emptyList(),
val isUppercase: Boolean = false,
) : State {
override val items = if (isUppercase) {
originItems.map { it.uppercase() }
} else {
originItems
}
}And the last step - create a reducer of private state:
// Reducer with private StateImpl:
private val reducer: Reducer<StateImpl> = getItems()
.toReducer(
initialState = StateImpl(),
nextState = StateImpl::copy,
)
// Output StateFlow with public State:
val stateFlow: StateFlow<State> = reducer.stateFlowAll examples above leveraged the usage of Reducer<T> type. Now let's consider
all ways how reducers can be created. Here is the list of converter functions:
Flow<T>.toReducer()- creates a simpleReducer<T>Flow<T>.toContainerReducer()- creates aContainerReducer<T>Flow<Container<T>>.containerToReducer()- creates aContainerReducer<T>with automatic management of containers coming from origin flow
Also, you can combine 2 or more Kotlin Flows into one Reducer by using:
combineToReducer(): Reducer<State>combineToContainerReducer(): ContainerReducer<State>combineContainersToReducer(): ContainerReducer<State>
As you see, the library has 2 types of reducers:
Reducer- providesStateFlow<T>for observing state, andupdate()method for updating valuesContainerReducer- providesStateFlow<Container<T>>for observing state wrapped intoContaineralong with 2 methods for updating:update()andupdateState(). TheupdateState()method allows you to modify values directly in the container.
Effectively, ContainerReducer<T> is a Reducer<Container<T>>
Let's look at the example of combining flows into Reducer:
data class State(
// collected from getNumberFlow(): Flow<Int>:
val number: Int = 0,
// collected from getTextFlow(): Flow<String>:
val text: String = "",
// updated manually:
val other: Boolean = false,
)
// combine flows to Reducer:
private val reducer: Reducer<State> = combineToReducer(
flow1 = getNumberFlow(), // Flow<Int>
flow2 = getTextFlow(), // Flow<String>
initialState = State(),
nextState = State::copy,
// 'scope' and 'started' can be omitted within ReducerOwner:
scope = viewModelScope,
started = SharingStarted.Lazily,
)
val stateFlow: StateFlow<State> = reducer.stateFlowState is updated automatically whenever new values come from input flows using
implicit copy() method from data class in this case. Also, you can update your
state manually:
reducer.update { oldState ->
oldState.copy(other = true)
}Sometimes you may need to wrap resulting State class into container to indicate loading status in your screen. For a such purposes, you can convert Flows into ContainerReducer.
Example of using combineToContainerReducer:
data class State(
val number: Int = 0, // from flow1
val text: String = "", // from flow2
val other: Boolean = false,
)
private val reducer: ContainerReducer<State> = combineToContainerReducer(
flow1 = getIntFlow(), // Flow<Int>
flow2 = getStringFlow(), // Flow<String>
initialState = ::State,
nextState = State::copy,
scope = viewModelScope,
started = SharingStarted.Lazily,
)
val stateFlow: StateFlow<Container<State>> = reducer.stateFlowExample of simple converting of one origin flow:
data class State(
val number: Int = 0,
val other: Boolean = false,
)
private val reducer: ContainerReducer<State> = getIntFlow() // Flow<Int>
.toContainerReducer(
initialState = ::State,
nextState = State::copy,
scope = viewModelScope,
started = SharingStarted.Lazily,
)
val stateFlow: StateFlow<Container<State>> = reducer.stateFlowBy the way, nextState arg is optional upon creating a ContainerReducer instance,
if you don't need to update your state class manually. Also, as mentioned above,
scope and started args may be also omitted within ReducerOwner implementations:
data class State(
val number: Int = 0,
)
private val reducer: ContainerReducer<State> = getIntFlow() // Flow<Int>
.toContainerReducer(initialState = ::State)
val stateFlow: StateFlow<Container<State>> = reducer.stateFlowIf your input flows have a type Flow<Container<T>>, you can use another two
functions for creating ContainerReducers that will mirror container status
from origin flows:
data class State(
val number: Int = 0,
)
private val reducer: ContainerReducer<State> = getContainerIntFlow() // Flow<Container<Int>>
.containerToReducer(initialState = ::State)
val stateFlow: StateFlow<Container<State>> = reducer.stateFlowOr:
data class State(
val number: Int = 0, // from flow1
val text: String = "", // from flow2
)
private val reducer: ContainerReducer<State> = combineContainersToReducer(
flow1 = getContainerIntFlow(), // Flow<Container<Int>>
flow2 = getContainerStringFlow(), // Flow<Container<String>>
initialState = ::State,
)
val stateFlow: StateFlow<Container<State>> = reducer.stateFlowIn case if you need to add additional properties to your state which are
not updated by input flows, you need to provide nextState arg:
data class State(
val number: Int = 0, // from flow1
val text: String = "", // from flow2
val manuallyUpdatedProperty: Boolean = false,
)
private val reducer: ContainerReducer<State> = combineContainersToReducer(
flow1 = getContainerIntFlow(), // Flow<Container<Int>>
flow2 = getContainerStringFlow(), // Flow<Container<String>>
initialState = ::State,
nextState = State::copy,
)
val stateFlow: StateFlow<Container<State>> = reducer.stateFlow
reducer.updateState { oldState ->
oldState.copy(manuallyUpdatedProperty = true)
}There are a couple of extension functions which can simplify code working
with Container<T> instances:
fold,foldDefault,foldNullabletransformmap,mapExceptioncatch,catchAllgetOrNull,exceptionOrNull,unwrap
And ready-to-use integration with Kotlin Flows is available out of the box:
Flow<Container<T>>.containerMap()converts the flow of typeContainer<T>into a flow of typeContainer<R>StateFlow<Container<T>>.containerStateMap()converts StateFlow of typeContainer<T>into a StateFlow of typeContainer<R>Flow<Container<T>>.containerFilter()filters allContainer.Success<T>values by a given predicate
Other extension functions:
Flow<Container<T>>.containerFilterNotFlow<Container<T>>.containerMapLatestFlow<Container<T>>.containerFoldFlow<Container<T>>.containerFoldDefaultFlow<Container<T>>.containerFoldNullableFlow<Container<T>>.containerTransformFlow<Container<T>>.containerCatchFlow<Container<T>>.containerCatchAll
Also, there are the following type aliases:
ListContainer<T>=Container<List<T>>ContainerFlow<T>=Flow<Container<T>>ListContainerFlow<T>=Flow<Container<List<T>>>
Combine container flows:
val flow1: Flow<Container<String>> = ...
val flow2: Flow<Container<Int>> = ...
val combinedFlow: Flow<Container<String>> =
combineContainerFlows(flow1, flow2) { string, number ->
"$string,$number"
}Combine a container flow with other flows:
val flow1: Flow<Container<String>> = ...
val flow2: Flow<Int> = ...
val combinedFlow: Flow<Container<String>> = flow1
.containerCombineWith(flow2) { string, number ->
"$string,$number"
}Subjects are classes controlling flow emissions (name is taken from Reactive Streams)
FlowSubject represents a finite Flow which emission is controlled outside
(like StateFlow and SharedFlow). The FlowSubject holds the latest value but
there are differences from StateFlow:
FlowSubjectis a finite flow and it can be completed by usingonCompleteandonErrormethodsFlowSubjectdoesn't need a starting default valueFlowSubjectdoesn't hold the latest value if it has been completed with error
Usage example:
val flowSubject = FlowSubject.create<String>()
flowSubject.onNext("first")
flowSubject.onNext("second")
flowSubject.onComplete()
flowSubject.listen().collect {
// ...
}LazyFlowSubject<T> provides a mechanism of converting a load
function into a Flow<Container<T>>.
Features:
- The load function is executed only when at least one subscriber starts collecting the flow
- The load function can emit more than one value
- The load function is cancelled when the last subscriber stops collecting the flow after timeout (default timeout = 1sec)
- The latest result is cached, so any new subscriber can receive the most actual loaded value without triggering the load function again and again
- There is a timeout (default value is 1sec) after the last subscriber stops collecting the flow. When timeout expires, the cached value is cleared so any further subscribers will execute the load function again
- The flow can be collected by using
listen()method - You can replace the load function at any time by using the following methods:
newLoad- assign a new load function which can emit more than one value. This method also returns a separate flow which differs from the flow returned bylisten(): it emits only values emitted by a new load function and completes as soon as a new load function completesnewAsyncLoad- the same asnewLoadbut it returns Unit immediatelynewSimpleLoad- assign a new load function which can emit only one value. This is a suspend function and it waits until a new load function completes and returns its result (or throws an exception)newSimpleAsyncLoad- the same asnewSimpleLoadbut it doesn't wait for load results and returns immediately
- Also you can use
updateWithmethod in order to cancel any active loader and place your own value immediately to the subject. The previous loader function will be used again if you callreload()or if cache has been expired.
Usage example:
class ProductRepository(
private val productsLocalDataSource: ProductsLocalDataSource,
private val productsRemoteDataSource: ProductsRemoteDataSource,
) {
private val productsSubject = LazyFlowSubject.create {
val localProducts = productsLocalDataSource.getProducts()
if (localProducts != null) emit(localProducts)
val remoteProducts = productsRemoteDataSource.getProducts()
productsLocalDataSource.saveProducts(remoteProducts)
emit(remoteProducts)
}
// ListContainerFlow<T> is an alias to Flow<Container<List<T>>>
fun listenProducts(): ListContainerFlow<Product> {
return productsSubject.listen()
}
fun reload() {
productsSubject.reloadAsync()
}
}You can access an additional field named loadTrigger within the
LazyFlowSubject.create { ... } block. Depending on its value, you can change the load
logic. For example, you can skip loading data from the local cache if the loading process has
been initiated by reload call:
private val productsSubject = LazyFlowSubject.create {
if (loadTrigger != LoadTrigger.Reload) {
val localProducts = productsLocalDataSource.getProducts()
if (localProducts != null) emit(localProducts)
}
val remoteProducts = productsRemoteDataSource.getProducts()
productsLocalDataSource.saveProducts(localProducts)
emit(remoteProducts)
}Optionally you can assign a SourceType to any Container.Success value
just to let them know about an actual source where data arrived from.
// in loader function:
val subject = LazyFlowSubject.create {
val remoteProducts = productsRemoteDataSource.getProducts()
emit(remoteProducts, RemoteSourceType)
}
// in `Container.Success()` directly:
subject.updateWith(Container.Success("hello", FakeSourceType))
// in `Container.updateIfSuccess`:
subject.updateIfSuccess(ImmediateSourceType) { oldValue ->
oldValue.copy(isFavorite = true)
}Source types can be accessed via Container.Success instance:
subject.listen()
.filterIsInstance<Container.Success<String>>()
.collectLatest { successContainer ->
val value = successContainer.value
val sourceType = successContainer.source
println("$value, isRemote = ${sourceType == RemoteSourceType}")
}All success and error containers have an additional property named reloadFunction.
By default, it is empty, but optionally you can configure the LazyFlowSubject.listen()
call with additional ContainerConfiguration(emitReloadFunction = true) argument. In this
case, calling reloadFunction causes a full reload of the corresponding LazeFlowSubject
instance (similar to call of LazyFlowSubject.reloadAsync()).
You can re-assign the reloading function by:
-
creating a new container:
successContainer(value, reloadFunction = customReloadFunction) -
mapping an existing container using
updatefunction:Container<T>.update { reloadFunction = { println("Reloading...") reloadFunction(it) // call origin reload function if needed } }
-
mapping an existing container in a Kotlin Flow:
val flow: Flow<Container<String>> = getFlow() return flow .containerUpdate { println("Reloading...") reloadFunction(it) // call origin reload function if needed }
LazyCache is a store of multiple LazyFlowSubject instances. It allows you
defining listeners and loader functions with additional arguments.
val lazyCache = LazyCache.create<Long, User> { id ->
val localUser = localUsersDataSource.getUserById(id)
if (localUser != null) {
emit(localUser)
}
val remoteUser = remoteUsersDataSource.getUserById(id)
localUsersDataSource.save(remoteUser)
emit(remoteUser)
}
fun getUserById(id: Long): Flow<Container<User>> {
return lazyCache.listen(id)
}