Scalio

Android-Kotlin

Command-Based Architecture for Android

Mikhail Gurevich

Mikhail Gurevich

Principal Android Engineer

Command-Based Architecture for Android

Problems we want to solve

Almost every application has data that is shown in a list. A user is  able to interact with this data in various ways, such as liking, favoriting, and removing. We also usually give the user an ability to refresh the list data, load data via pages, or only display new data.

All of these requirements make it difficult to implement a view properly. If we don't synchronize requests we may have strange behaviors due to unpredictable reasons: user interaction event and order, network latency, and even service availability. All of these cases should be covered if we want to have a good working product, and this is where implementing a Command Based Architecture helps us.


Let’s build a sample Android application...

 

User use-cases we want to cover

  1. AAU I want to see an indicator when loading the initial list
  2. AAU I want to see an error message and retry button if initial loading fails
  3. AAU I want to see a special empty message if loading was successful but no data was passed
  4. AAU I want to see a list of data if initial loading was successful and at least one item was returned from the API
  5. AAU I want to pull-to-refresh (p2r) to refresh page data at any time
  6. AAU I want to see additional data as I scroll down the page
  7. AAU I want to change item favorite status
  8. AAU I want to see immediate favorite status change after the click

 

Let’s start from the beginning and create additional developer use-cases that we want to cover in addition to our user stories.
 

Handling pagination:

  1. AAD I want to have only one initial loading request at a time
  2. AAD I want to have only one p2r loading request at a time
  3. AAD I do NOT want to have any p2r loading request if initial is running
  4. AAD I want to only have one next page loading request at a time

These requirements are not too difficult. There are several libraries that are able to solve the above use-cases, including custom state machines or libraries. These are out of the scope of this sample but other possible solutions can be found online.

 

Handling favorite status change:

  1. AAD I want to be able to execute favorite change request asynchronously for each item (based on uid)
  2. AAD I want to have only one favorite change request at a time for each uid
  3. AAD I want to have as few favorite change requests as possible
  4. AAD I want to have good favorite error handling logic with UI state changes if needed

 

The above use-cases are a bit more difficult because we have to save different states now: what tasks are running and what was the latest server synced value? There are much fewer libraries that are able to handle all above cases, which causes most of this code to fall on the shoulders of the developer. But nothing is impossible!

 

Also, we forgot a couple large use-cases that are not handled above:

  1. AAU I want to have a correct UI for the case: change favorite state and perform p2r when the change favorite state was delayed longer than the p2r request

 

In the above case, p2r can return to the old server value if the change favorite status request is still in progress. However, the user would like to see their change locally and track the favorite request status. Because if it fails, it will be nice to reverse the change and perhaps warn the user.

 

We additionally have a bit more developer use-cases:

  1. AAD I want to sync p2r and favorite requests to understand which data and changes are synced and which are not
  2. AAD I want to have immediate favorite status update represented on the UI

 

These new cases are much more complicated because we have to know pagination and favorite requests states and we are not able to encapsulate them. This becomes much more interesting if we have favorites, likes, remove, or any additional functionality. Without an appropriate way of syncing, our system can become unpredictable. Like in the gif below —the  user can change favorite status from unfavorite to favorite and back. Two items were updated correctly, but one was updated from the server incorrectly.

Raise condition when adding/removing favorites

 

Possible solution that can fit ANY logic

The above cases can be rewritten as a single command that contains all dependencies and solves all above requirements. Let’s make it happen!
 

FavoriteChangeCommand

 

Action:

  1. Change favorite status if it was updated

 

Rules:

  1. Only one command can be executed at a time for each item uid
  2. Commands can be executed asynchronously if they are for different item uids
  3. Commands should be executed only if there are no other running commands for this item uid; otherwise, it should be added to the execution queue and remove all other pending commands for this item uid
  4. After successful execution, we should set the new server synced state and execute any other pending command for this item uid
  5. After failed execution, we should revert status and warn the user only if there is no other command in the queue; otherwise, we should start the other command immediately

 

RefreshCommand

 

Action:

  1. Control initial loading and p2r

     

Rules:

  1. Only one command of this type can be executed at a time
  2. Should postpone any other command (favorite or next page load) if running

     

NextPageLoadCommand

 

Action:

  1. Load all next pages (except initial one)

     

Rules:

  1. Only one command of this type can be executed at a time
  2. Should not be executed or added to a queue if RefreshCommand is in the queue or already running (it doesn’t make sense to load page number 5 when there is an active request to load the initial one)

     

If we’re able to encapsulate this information to create commands with the ability to sync with the above rules, then that will solve all our other use-cases!


 

Command Based Architecture basics

Our command based architecture library can solve all the above. Let's code a bit!

 

Base classes

 

ActionCommand is the base class for each command. There are two sets of methods in that class.

The first set of methods controls the execution lifecycle and supports data state change during execution:

  1. Called when command was added to execution queue (may be called multiple times)
  2. Called right before command starts execution
  3. Main execution method — should return command result or fail with exception
  4. Called if command executed normally
  5. Called if command executed with error
  6. Always called after success or fail (like final block in Java)

     

Any lifecycle method that gets current data state and can update by copying the existing one with appropriate new values. If the new dataState will be changed, the CommandManager will push the new state to the UI (via LiveData).

    open fun onCommandWasAdded(dataState: DataState): DataState = dataState    open fun onExecuteStarting(dataState: DataState): DataState = dataState    abstract suspend fun executeCommand(dataState: DataState): CommandResult    open fun onExecuteSuccess(dataState: DataState, result: CommandResult): DataState = dataState    open fun onExecuteFail(dataState: DataState, error: Throwable): DataState = dataState    open fun onExecuteFinished(dataState: DataState): DataState = dataState


 

The second set of methods controls the execution strategy:

  1. Control if the command should ever be executed or skipped based on the current data, pending commands, or already running commands
  2. Should block other commands from execution while it is running
  3. Control if the command can be executed immediately or should delay

    abstract fun shouldAddToPendingActions(        dataState: DataState,        pendingActionCommands: RemoveOnlyList<ActionCommand<*, *>>,        runningActionCommands: List<ActionCommand<*, *>>    ): Boolean    abstract fun shouldBlockOtherTask(pendingActionCommand: ActionCommand<*, *>): Boolean    abstract fun shouldExecuteAction(        dataState: DataState,        pendingActionCommands: RemoveOnlyList<ActionCommand<*, *>>,        runningActionCommands: List<ActionCommand<*, *>>    ): Boolean 
 


 

ExecutionStrategy  
A helper class for common execution strategies that can be shared across different commands.

 

ConcurrentStrategy

Allows the user to execute each command asynchronous without blocking any other command from execution.

open class ConcurrentStrategy : ExecutionStrategy {    override fun shouldAddToPendingActions(        pendingActionCommands: RemoveOnlyList<ActionCommand<*, *>>,        runningActionCommands: List<ActionCommand<*, *>>    ): Boolean =        true    override fun shouldBlockOtherTask(pendingActionCommand: ActionCommand<*, *>): Boolean =        false    override fun shouldExecuteAction(        pendingActionCommands: RemoveOnlyList<ActionCommand<*, *>>,        runningActionCommands: List<ActionCommand<*, *>>    ): Boolean =        true }


 

ConcurrentStrategyWithTag

Same as ConcurrentStrategy, but will remove any other pending commands with the same tag and wait for previous command execution with the same uid.

open class ConcurrentStrategyWithTag(private val tag: Any) : ConcurrentStrategy() {    override fun shouldAddToPendingActions(        pendingActionCommands: RemoveOnlyList<ActionCommand<*, *>>,        runningActionCommands: List<ActionCommand<*, *>>    ): Boolean {        pendingActionCommands.removeAll { command ->            command.strategy.let { it is ConcurrentStrategyWithTag && it.tag == tag }        }        return true    }    override fun shouldExecuteAction(        pendingActionCommands: RemoveOnlyList<ActionCommand<*, *>>,        runningActionCommands: List<ActionCommand<*, *>>    ): Boolean =        !runningActionCommands.any { command ->            command.strategy.let { it is ConcurrentStrategyWithTag && it.tag == tag }        } }


 

SingleStrategy

Will be executed only in a single action and any other commands will be postponed from execution. It only allows one command with this strategy to exist in the execution queue.

open class SingleStrategy : ExecutionStrategy {    override fun shouldAddToPendingActions(        pendingActionCommands: RemoveOnlyList<ActionCommand<*, *>>,        runningActionCommands: List<ActionCommand<*, *>>    ): Boolean =        !pendingActionCommands.any { it.strategy is SingleStrategy }                && !runningActionCommands.any { it.strategy is SingleStrategy }    override fun shouldBlockOtherTask(pendingActionCommand: ActionCommand<*, *>): Boolean =        true    override fun shouldExecuteAction(        pendingActionCommands: RemoveOnlyList<ActionCommand<*, *>>,        runningActionCommands: List<ActionCommand<*, *>>    ): Boolean =        runningActionCommands.isEmpty() }


 

SingleStrategyWithTag

It is similar to the SingleStrategy, but will allow only commands with different tags to be added to the execution queue.

open class SingleWithTagStrategy(private val tag: Any) : SingleStrategy() {    override fun shouldAddToPendingActions(        pendingActionCommands: RemoveOnlyList<ActionCommand<*, *>>,        runningActionCommands: List<ActionCommand<*, *>>    ): Boolean =        !pendingActionCommands.any { command ->            command.strategy.let { it is SingleWithTagStrategy && it.tag == tag }        }                &&                !runningActionCommands.any { command ->                    command.strategy.let { it is SingleWithTagStrategy && it.tag == tag }                } }


ActionCommandWithStrategy 

It will route execution strategy methods to the past ExecutionStrategy class and leave for subclass-only lifecycle methods.


 

Lets code our sample application

RefreshCommand

  1. Should add refresh indicator state in onCommandWasAdded
  2. Should perform request on background thread in executeCommand
  3. Should remove refresh indicator state in onExecuteSuccess
  4. Should swap old list with a new one in onExecuteSuccess
  5. Should save next page information in onExecuteSuccess (page number, latest loaded item. or whatever is required) if any or remove it if there is no next page
  6. Should remove refresh indicator state in onExecuteFail
  7. Should have its own RefreshStrategy that extends SingleWithTagStrategy (“RefreshStrategy”)

     

LoadNextCommand

  1. Should add next page loading indicator state in onCommandWasAdded
  2. Should perform request on background thread in executeCommand
  3. Should remove next page loading indicator state in onExecuteSuccess
  4. Should append new data to the existing list in onExecuteSuccess
  5. Should save next page information in onExecuteSuccess (page number, or latest loaded item or whatever is needed) if any or remove it if there is no next page
  6. Should remove next page loading indicator state in onExecuteFail
  7. Should have its own LoadNextStrategy that extends SingleWithTagStrategy(“LoadNext”) and skip this command if any RefreshStrategy is already in the queue or running

     

ChangeFavoriteStatusCommand

  1. Should save previous server state and new pending state in onCommandWasAdded
  2. Should perform request if pending state is still the one that was requested in this command on background thread in executeCommand
  3. Should change server state to a new one and keep pending state if it is newer than current in onExecuteSuccess
  4. Should revert data to server state if pending state is still the same as requested in this command in onExecuteFail
  5. Should have SingleWithTagStrategy(itemUid) strategy

     

As you can see, the rules above will completely cover all the necessary use-cases that we described earlier. The only thing that we need to do is implement appropriate data structure and missed logic.

 

Since pagination is a common task, we created base classes

Page data information:

  1. PageData: for page data that has list of items
  2. PageDataWithNextPageNumber: If you also need page data with next page information as a number
  3. PageDataWithLatestItem: if you also need page data with next page information as latest item

     

Pagination loading state — PaginationState:

  1. Has PageData object
  2. RefreshState for p2r action states
  3. NextPageLoadingState for next page action states

     

Commands:

  1. RefreshCommand: contains everything except real request and basic UI items for each state
  2. LoadNextCommand: base class that has everything except real request and basic UI items for each state
  3. LoadNextWithPageNumberCommand: an implementation for PageDataWithNextPageNumber page data
  4. LoadNextWithLatestItemCommand: an implementation for PageDataWithLatestItem page data

     


Result implementation

 

We used already-implemented commands for pagination and custom for favorite status change:

class SimpleListViewModel(application: Application) : ListViewModel(application) {    private val mutableScreenState = MutableLiveData(        ListScreenState(null, null, null)    )    override val screenState: LiveData<ListScreenState> = mutableScreenState    override val commandManager: CommandManager<ListScreenState> by lazy {        CommandManagerImpl(mutableScreenState, viewModelScope)    }    init {        reload()    }    override fun reload() {        commandManager.postCommand(            RefreshCommand(                { executeLoadNextPage(0) },                { UIProgressErrorItem.Progress },                { UIProgressErrorItem.Error(it.toString()) { reload() } }            )        )    }    override fun loadNextPage() {        commandManager.postCommand(            LoadNextWithPageNumberCommand(                { executeLoadNextPage(it) },                { UIProgressErrorItem.Progress },                { UIProgressErrorItem.Error(it.toString()) { loadNextPage() } }            )        )    }    private suspend fun executeLoadNextPage(pageNumber: Int): PageDataWithNextPageNumber<UIMainItem> {        val items =            withContext(Dispatchers.IO) {                HardCodeRepository.loadNextMainPage(pageNumber, PAGE_SIZE)            }        return PageDataWithNextPageNumber(            withContext(Dispatchers.Default) { items.map { it.toUIMainItem() } },            if (items.isEmpty()) null else pageNumber + 1        )    }    override fun removeFromFavorite(item: UIMainItem) {        if (item.favoriteState.favorite) {            executeFavoriteChangeAction(item.uid, false)        }    }    override fun addToFavorite(item: UIMainItem) {        if (!item.favoriteState.favorite) {            executeFavoriteChangeAction(item.uid, true)        }    }    private fun executeFavoriteChangeAction(uid: String, newFavoriteStatus: Boolean) {        commandManager.postCommand(            ChangeFavoriteStatusCommand(                uid,                newFavoriteStatus,                {                    withContext(Dispatchers.IO) {                        HardCodeRepository.changeFavoriteStatus(uid, newFavoriteStatus)                    }                },                { Toast.makeText(getApplication(), it.toString(), Toast.LENGTH_SHORT).show() }            )        )    } }


ViewModel implementation:

internal class ChangeFavoriteStatusCommand(    private val mainItemUid: String,    private val changeToFavorite: Boolean,    private val changeFavoriteAction: suspend () -> Unit,    private val onFavoriteChangeFailed: (Throwable) -> Unit ) : ActionCommandWithStrategy<Unit, ListScreenState>(ConcurrentStrategyWithTag(mainItemUid)) {    override fun onCommandWasAdded(dataState: ListScreenState): ListScreenState =        // we update item state only if the list contains this item        if (dataState.pageData?.itemsList?.any { it.key == mainItemUid } == true) {            dataState.copy(                pageData = dataState.pageData?.updateItemFavoriteState {                    it.newSelection(changeToFavorite)                }            )        } else {            dataState        }    override suspend fun executeCommand(dataState: ListScreenState) {        val uiMainItem = dataState.pageData?.itemsList?.firstOrNull { it.key == mainItemUid }        if (uiMainItem?.favoriteState is FavoriteState.PreSelectProgress && uiMainItem.favoriteState.hasChange()) {            // we should run this command only if there was a change. otherwise we should skip it            changeFavoriteAction()        }    }    override fun onExecuteSuccess(dataState: ListScreenState, result: Unit): ListScreenState =        if (dataState.pageData?.itemsList?.any { it.key == mainItemUid } == true) {            dataState.copy(                pageData = dataState.pageData?.updateItemFavoriteState {                    it.newFinalState(changeToFavorite)                }            )        } else {            dataState        }    override fun onExecuteFail(dataState: ListScreenState, error: Throwable): ListScreenState =        if (dataState.pageData?.itemsList?.any { it.key == mainItemUid } == true) {            var hasChange = false            val newData = dataState.copy(                pageData = dataState.pageData?.updateItemFavoriteState {                    val newFavoriteState = it.revertState(!changeToFavorite)                    if (it.favorite != newFavoriteState.favorite) {                        hasChange = true                    }                    newFavoriteState                }            )            if (hasChange) onFavoriteChangeFailed(error)            newData        } else {            dataState        }    private fun PageDataWithNextPageNumber<UIMainItem>.updateItemFavoriteState(        newStateGenerator: (FavoriteState) -> FavoriteState    ): PageDataWithNextPageNumber<UIMainItem> =        PageDataWithNextPageNumber(            itemsList.map {                if (it.key == mainItemUid)                    it.copy(favoriteState = newStateGenerator(it.favoriteState))                else                    it            },            nextPageNumber        ) }

Favorite change command implementation:

internal class ChangeFavoriteStatusCommand(    private val mainItemUid: String,    private val changeToFavorite: Boolean,    private val changeFavoriteAction: suspend () -> Unit,    private val onFavoriteChangeFailed: (Throwable) -> Unit ) : ActionCommandWithStrategy<Unit, ListScreenState>(ConcurrentStrategyWithTag(mainItemUid)) {    override fun onCommandWasAdded(dataState: ListScreenState): ListScreenState =        // we update item state only if the list contains this item        if (dataState.pageData?.itemsList?.any { it.key == mainItemUid } == true) {            dataState.copy(                pageData = dataState.pageData?.updateItemFavoriteState {                    it.newSelection(changeToFavorite)                }            )        } else {            dataState        }    override suspend fun executeCommand(dataState: ListScreenState) {        val uiMainItem = dataState.pageData?.itemsList?.firstOrNull { it.key == mainItemUid }        if (uiMainItem?.favoriteState is FavoriteState.PreSelectProgress && uiMainItem.favoriteState.hasChange()) {            // we should run this command only if there was a change. otherwise we should skip it            changeFavoriteAction()        }    }    override fun onExecuteSuccess(dataState: ListScreenState, result: Unit): ListScreenState =        if (dataState.pageData?.itemsList?.any { it.key == mainItemUid } == true) {            dataState.copy(                pageData = dataState.pageData?.updateItemFavoriteState {                    it.newFinalState(changeToFavorite)                }            )        } else {            dataState        }    override fun onExecuteFail(dataState: ListScreenState, error: Throwable): ListScreenState =        if (dataState.pageData?.itemsList?.any { it.key == mainItemUid } == true) {            var hasChange = false            val newData = dataState.copy(                pageData = dataState.pageData?.updateItemFavoriteState {                    val newFavoriteState = it.revertState(!changeToFavorite)                    if (it.favorite != newFavoriteState.favorite) {                        hasChange = true                    }                    newFavoriteState                }            )            if (hasChange) onFavoriteChangeFailed(error)            newData        } else {            dataState        }    private fun PageDataWithNextPageNumber<UIMainItem>.updateItemFavoriteState(        newStateGenerator: (FavoriteState) -> FavoriteState    ): PageDataWithNextPageNumber<UIMainItem> =        PageDataWithNextPageNumber(            itemsList.map {                if (it.key == mainItemUid)                    it.copy(favoriteState = newStateGenerator(it.favoriteState))                else                    it            },            nextPageNumber        ) }

This approach gives you the ability to sync any command and use-case between each other, even if you have split their logic to different classes.
 

The best thing that you can do is to have a root command manager that will be able to broadcast any update from any child command manager to others, so you will be able to update data after changing in a command manager. This and the current approach can be found in our public Scalio Github repository.
 

Please feel free to ask any questions here, on github, or via my private email.
 

Thanks for reading!

How can we help you?