Skip to content

Platform Composable Architecture

Composable Architecture aOS Implementation

High-level View

This is a high level overview of the different parts of the architecture.

  • Views Any element subscribed to the store to be notified of state changes. While typically in UI elements, other app elements could also react to state changes.
  • Action Simple structs describing an event by the user, but also from other sources or in response to other actions (from effects). Actions are the only way to change state. Views send actions to the Store which handles them in the main thread as they come.
  • Store The central application hub that contains the whole app state, handles the actions, passes them to the reducers and fires effects.
  • State The single source of truth for the whole app. This data class is typically empty when the application starts and is filled after every action.
  • Reducers Reducers are pure functions that take the state and an action to produce a new state. They can result in an array of effects that asynchronously send further actions. All business logic should reside in them.
  • Effects As mentioned, reducers can produce these after handling an action. They are classes that return an optional action. All the effects emitted from a reducer are batched, meaning the state change is emitted once all actions are handled.
  • Subscriptions Subscriptions are emitting actions based on some underling observable API and/or state changes.

There's one global Store and one AppState. But we can view the Store to access sub-stores working on one part of the state.

There's also one main Reducer and multiple sub-reducers that handle a limited set of actions and only a part of the state. Those reducers are then pulled back and combined into the main reducer.

Composable Architecture Components

Action

Actions are sealed classes, which makes it easier to discover which actions are available and also add the certainty that we are handling all of them in reducers.

kotlin
sealed class AccountListAction {
   data object Fetch : AccountListAction()
   data class Loaded(val data: List<Account>) : AccountListAction()
}

State & Store

The Store exposes a flow which emits the whole state of the app every time there's a change and a method to send actions that will modify that state. The State is just a data class that contains ALL the state of the application, this data class will be probably empty when the application start and will be updated after every action. It also includes the local state of all the specific modules that need local state.

The store interface looks like this:

kotlin
interface Store<State, Action : Any> {
   val state: Flow<State>
   fun send(actions: List<Action>)
   // more code
}

And you can create a new store using:

kotlin
createGlobalStore(
   initialState = AppState(),
   reducer = reducer,
   subscription = subscription,
   storeScopeProvider = application as StoreScopeProvider
)

Actions are sent like this:

kotlin
store.send(AccountListAction.Fetch)

and views can subscribe like this:

kotlin
store.state
   .collectLatest {

   }
   .launchIn(scope)

// or

store.state
   .onEach { Log.d(tag, "The whole state: \($0)") }
   .launchIn(scope)

Reducers

Reducer transforms States in response to Actions. It accepts the current State and Action and returns a new State. They optionally result in an array of SideEffects that will asynchronously send further actions. All business logic should reside in them.

Reducers are classes that implement following interface :

kotlin
fun interface Reducer<State, Action> {
   fun reduce(state: State, action: Action): ReduceResult<State, Action>
}

SideEffects

In order to send actions asynchronously we use SideEffects. Reducers return an array of SideEffects. The store waits for those side effects and sends whatever action they emit, if any. SideEffects manage tasks such as data fetching or accessing persistent storage, which fall outside the scope of synchronous State updates. They are crucial for handling asynchronous operations within a Composable Architecture.

SideEffects are classes that implement following interface :

kotlin
fun interface Effect<out Action> {
   operator fun invoke(): Flow<Action>
}

Send Action

send Function takes an action and send it to the reducer, reducer perform the action and returns new state.

kotlin
    store.send(AccountListAction.Fetch)

Adopting Composable Architecture – A Step-by-Step Guide

To adopt Composable Architecture, perform the following steps:

  1. Create Actions :

Create the Action sealed class and define all actions supported by Journey/Plugin.

When sending action parameters to the logger, it is essential to annotate them with the @PrivacyLevel annotation. This ensures that the privacy level of the data being logged is explicitly defined and handled appropriately.

Example :

kotlin
sealed class AccountListAction {
   data object Fetch : AccountListAction()
   data class Loaded(val data: List<Account>) : AccountListAction()
}

sealed class AccountDetailsAction {

    data class Get(
        @PrivacyLevel(PrivacySchemaModel.Privacy.PUBLIC)
        val number: String,
        val scenario: Scenario? = null,
        val refresh: Boolean = false,
    ) : AccountDetailsAction()
}

In the above example, we defined Actions for AccountList and AccountDetails, such as Fetch, Loaded, Get. The number field in Get action is annotated as PUBLIC, indicating that it can be safely logged without additional restriction.

  1. Create States :

For every action we need to maintain a State of Action and for that we need to create the State for each Action in separate State class.

Example :

kotlin
@ReduxState
@Serializable
data class AccountListUIState(
   @Privacy(Privacy.Privacy.INTERNAL)
   val isLoading: Boolean = false,
   @Privacy(Privacy.Privacy.PUBLIC)
   val isError: Boolean = false,
   val data: List<Account> = emptyList(),
)

For example as shown above we have defined initial state for AccountList.

  1. Create Reducer :

Create Reducer to change or update state of each Action State, so it handles all the State Changes of views.

Example :

kotlin
class AccountListReducer : Reducer<AccountListUIState, AccountListAction> {
   override fun reduce(
      state: AccountListUIState,
      action: AccountListAction,
   ): ReduceResult<AccountListUIState, AccountListAction> =
      when (action) {
         is AccountListAction.Fetch -> state.copy(
            isLoading = true,
         ).withFlowEffect(PluginSideEffects().invoke())

         is AccountListAction.Loaded -> state.copy(
            isLoading = false,
            data = action.data.sortByCreditOrPrimary()
         ).withoutEffect()
      }
}
  1. Create SideEffect :

As we know, The SideEffect is used to fetch data from a local server or storage. To handle the interaction between a network and your local storage, create the SideEffect Class.

Example :

kotlin
class PluginSideEffects(
   private val repository: Repository = Repository(),
) : Effect<AccountListAction> {
   override fun invoke(): Flow<AccountListAction> = flow {
      val json = toJson(repository.getAccounts())
      val accounts = getPayloadList<Account>(JSONObject(json))
      emit(AccountListAction.Loaded(accounts))
   }.flowOn(dispatcherProvider.io)
}
  1. Create GlobalState :

Create a GlobalState data class to collectively manage multiple states.

Example :

kotlin
@Serializable
data class GlobalState(
   val accountsListUIState: AccountListUIState = AccountListUIState(),
   val accountDetailsState: AccountDetailsState = AccountDetailsState(),
   val accountTransactionsState: AccountTransactionsState = AccountTransactionsState(),
)
  1. Create GlobalReducer :

Create a GlobalReducer by combining all the reducers.

Example :

kotlin
val globalReducer: Reducer<AccountsState, GlobalAction> = combine(
   GlobalReducer(),
   accountListReducer.pullback(
      mapToLocalState = { it.accountsListUIState },
      mapToLocalAction = { (it as? GlobalAction.AccountListActions)?.action },
      mapToGlobalState = { globalState, accountListState -> globalState.copy(accountsListUIState = accountListState) },
      mapToGlobalAction = { GlobalAction.AccountListActions(it) }
   ),
   accountDetailsReducer.pullback(
      mapToLocalState = { it.accountDetailsState },
      mapToLocalAction = { (it as? GlobalAction.AccountDetailsActions)?.action },
      mapToGlobalState = { globalState, accountDetailsState ->
         globalState.copy(
            accountDetailsState = accountDetailsState
         )
      },
      mapToGlobalAction = { GlobalAction.AccountDetailsActions(it) }
   ),
   accountTransactionsReducer.pullback(
      mapToLocalState = { it.accountTransactionsState },
      mapToLocalAction = { (it as? GlobalAction.AccountTransactionsActions)?.action },
      mapToGlobalState = { globalState, accountTransactionsState ->
         globalState.copy(
            accountTransactionsState = accountTransactionsState
         )
      },
      mapToGlobalAction = { GlobalAction.AccountTransactionsActions(it) }
   )
)

Combine is a function in ReducerExtensions class, allowing multiple reducers of the same type in a single reducer. This is usually in combination with pullback to create the main reducer, which is then used to create a store.

  1. Create the Store :

Set up the Store with initial states and reducers. To initialize the Store, pass the global state , combined global reducer, StoreScopeProvider & plugin name.

Create StoreScopeProvider as below :

kotlin
val coroutineScope = object : CoroutineScope {
   override val coroutineContext: CoroutineContext
      get() = dispatcherProvider.main
}
val storeScopeProvider = StoreScopeProvider { coroutineScope }

:::caution Avoid using the deprecated dispatcherProvider method. Use the new version of the composable Redux library, which creates the Store on a background thread using Dispatcher.IO.limitedParallelism(1) to avoid ANR issues. Note that the new version does not take a dispatcher provided by the user. :::

kotlin
val store = createGlobalStore(
   initialState = GlobalState(),                                // Global state
   reducer = globalReducer,                                     // Combined global reducer
   storeScopeProvider = storeScopeProvider,                     // StoreScopeProvider
   pluginName = AccountsJourneyPlugin::class.simpleName ?: ""   // Plugin name
)

:::important The above changes are available in version 1.0.3. To incorporate these changes, you need to adopt the new version. :::

  1. Subscribe the State :

Monitor state changes by having view models or UI components listen to the state stream. In the example below, we are passing MutableStateFlow with AccountListUIState that was created earlier in step two.

kotlin
private val accountListStore =
   appStore.viewWithLogging<AccountsState, GlobalAction, AccountListUIState, AccountListAction>(
      mapToLocalState = { it.accountsListUIState },
      mapToGlobalAction = { GlobalAction.AccountListActions(it) }
   )

private val _accountListState: MutableStateFlow<AccountListUIState> =
   MutableStateFlow(AccountListUIState())

viewModelScope.launch {
   accountListStore.state.collectLatest {
      _accountListState.emit(it)
   }
}
  1. Send Action :

Trigger actions using the store's send function to alter the application's state.To trigger the action, callsend()` function using the Store object. For example, call Fetch Action on entering the screen & pullToRefresh to load the list from either the network API call or the local database.

kotlin
store.send(AccountListAction.Fetch)

This will send the Fetch Action and update states and views.

  1. SetUp Logger :

The Platform Composable Architecture library provides internal PlatformHub logging mechanism in the Extensions.kt class. So, Logger Initialization must be done in the host app Application class. Pass logger instance to the initializeLogger() function as below :

kotlin
LoggerHolder.initializeLogger(loggers)
  1. Enable Redux Logging :

To enable Redux logging, the LoggerHolder.startReduxLogging() method is called in the Application class during app initialization.

kotlin
LoggerHolder.startReduxLogging()