Streamlining User Actions with Command Pattern
Sr. Software Engineer Sourabh Saldi on using Command Pattern to streamline user actions in Jetpack compose.
Let's explore how we use command pattern to better manage user actions.
In the past, working with older view systems, we had a fairly commonly accepted way to expose live data or state from the view model, observe it in activities or fragments and then act on it to render a new UI state or some side effects.
In the Compose world, we started off by passing view models to screens and progressed to passing lambdas for user actions.
Using lambdas to represent each action a user performs on a screen is common. For instance, consider the following example.
MyModelScreen( items = item.data, onSave = viewModel::addMyModel, modifier = modifier )
Here we pass onSave = viewModel::addMyModel which works flawlessly.
Let’s imagine we had to add actions on this screen. The code might look something like this, which is perfectly functional and runs smoothly.
MyModelScreen( items = item.data, onSave = viewModel::onSaveClicked, onAdd = viewModel::onAddClicked, onUpdate = viewModel::onTextUpdate, onDelete = viewModel::onDeleteClicked, onList = viewModel::onListClicked, modifier = modifier )
This would only grow as we continue to add new functionalities.
Separating state and events can improve code readability, maintainability, and testability. So we started looking into alternative solutions. And this brings us to our motivation behind Command Pattern.
How Command Pattern helps here
We started to consider the principles of the Command Pattern to address this issue.
By definition, the pattern involves encapsulating a request as an object. This allows for greater flexibility in parameterizing clients with various requests, organizing requests in queues or logs, and providing support for undoing operations.
Before we dive in deeper, here's some basic terminology that you'll need to know:
Receiver: The object that receives and executes the command (ViewModel in our case).
Invoker: The object that sends the command to the receiver. (Buttons etc.)
Command Object: The command itself, which implements the execute method (see below eg.AddCommand) and has all the necessary info to complete the action.
Client: The application or component that is aware of the Receiver, Invoker, etc. (roughly matches our activity).
Now, let's take a closer look at the steps we took to put in place Command Patterns.
Step 1: Set up Command Receiver and Commands like given below
The Command pattern encapsulates a request in an object, which enables us to store & pass the command to a method and return the command like any other object.
We start off by defining a command receiver that acts as a contract for all actions available to the user for the given screen.
interface CommandReceiver { fun onAddClicked() fun onTextUpdate(newText: String) fun onDeleteClicked() fun onListClicked() fun onSaveClicked(text: String) fun processCommand(command: Command) { command.execute(this) } }
Then we move to Command itself which has one method execute, all the actions on this screen implement this method.
interface Command { fun execute(receiver: CommandReceiver) }
Then we write a Command Receiver and implementation for all commands (actions) that are allowed for users on the given screen.
class AddCommand : Command { override fun execute(receiver: CommandReceiver) { receiver.onAddClicked() } } class TextUpdateCommand(private val newText: String) : Command { override fun execute(receiver: CommandReceiver) { receiver.onTextUpdate(newText) } } class DeleteProductCommand : Command { override fun execute(receiver: CommandReceiver) { receiver.onDeleteClicked() } } class ListCommand() : Command { override fun execute(receiver: CommandReceiver) { receiver.onListClicked() } } class SaveCommand(private val text: String) : Command { override fun execute(receiver: CommandReceiver) { receiver.onSaveClicked(text) } }
Step 2: Update ViewModel to execute commands
Since ViewModel implements the contract CommandReceiver it also implements actions for all commands.
class MyModelViewModel @Inject constructor( private val myModelRepository: MyModelRepository ) : ViewModel(), CommandReceiver { override fun onAddClicked() { viewModelScope.launch { Log.v(TAG, "add command") } } override fun onTextUpdate(newText: String) { viewModelScope.launch { Log.v(TAG, "edit command") } } override fun onDeleteClicked() { viewModelScope.launch { Log.v(TAG, "delete command") } } override fun onListClicked() { viewModelScope.launch { Log.v(TAG, "list command") } } override fun onSaveClicked(text: String) { viewModelScope.launch { myModelRepository.add(text) } } companion object { const val TAG = "MyTag" } }
Step 3: Remove all lambdas for actions and pass the command processor
//Before MyModelScreen( items = items.data, onSave = viewModel::onSaveClicked, onAdd = viewModel::onAddClicked, onUpdate = viewModel::onTextUpdate, onDelete = viewModel::onDeleteClicked, onList = viewModel::onListClicked, modifier = modifier )
//After MyModelCommandScreen( items = items.data, modifier = modifier, commandProcessor = viewModel::processCommand )
Step 4: Commands from Screen to ViewModel (command pattern in action!)
We now pass commands as objects to the command receiver that executes
Icon( modifier = Modifier.clickable { commandProcessor(AddCommand()) }, imageVector = Icons.Filled.Add, contentDescription = "add" ) Icon( modifier = Modifier.clickable { commandProcessor(TextUpdateCommand(nameMyModel)) }, imageVector = Icons.Filled.Edit, contentDescription = "edit" ) Icon( modifier = Modifier.clickable { commandProcessor(DeleteProductCommand()) }, imageVector = Icons.Filled.Delete, contentDescription = "delete" ) Icon( modifier = Modifier.clickable { commandProcessor(ListCommand()) }, imageVector = Icons.Filled.List, contentDescription = "list" ) }
While it may seem tempting to use this in all components, it is recommended to use it only for screen-level composables (which are close to the root).
For component-level design, this approach may adversely affect re-usability, and we'd prefer if components don't directly manipulate screen state.
See this in action in the GitHub repository.
–
Did I mention we’re hiring? Looking to get involved in more projects like this? Check out our Engineering career page to learn more about our culture and current openings.
Thanks for reading!