State machines in the service of MVP. Yandex lecture
The state machine model (finite-state machine, FSM) finds application in writing code for a wide variety of platforms, including Android. It allows you to make the code less cumbersome, fits in well with the Model-View-Presenter (MVP) paradigm and is amenable to simple testing. Developer Vladislav Kuznetsov told the Droid Party how this model helps in the development of the Yandex.Disk application.
- First, let's talk about the theory. I think each of you heard about MVP and state machine, but we repeat.
')
Let's talk about motivation, why it’s all necessary and how it can help us. Let us turn to what we have done, on a real example, I will show pieces of code. And at the end we will talk about testing, about how this approach helped us to test everything conveniently.
The state machine and MVP or something similar - probably MVI - used everything.
State machines exist very much. Here is the simplest definition you can give them: it is a kind of mathematical abstraction, presented in the form of a finite set of states, events, and transitions from the current state to the new one, depending on the event.
Here is a simple diagram of some abstract programmer who sometimes sleeps, sometimes eats, but mostly writes code. This is enough for us. There are many types of finite state machines, but this is enough for us.
The scope of the state machine is quite large. For each item, they are used and successfully applied.
Like any approach, MVP divides our application into several layers. View - most often Activity or Fragment, the task of which is to forward some action to the user, to identify the Presenter that the user has done something. Model we consider as a data provider. It can be like a DB, if we are talking about clean architecture, or Interactor, anything can be. And Presenter is an intermediary that connects the View and the model, while it can take something from the model and update the View. This is enough for us.
Who can say in one sentence what a program is? Executable code? Too general, need more detail. Algorithm? The algorithm is a sequence of actions.
This is a dataset and some kind of control flow. It doesn't matter who manipulates this data: user or not. It follows from this that at any moment the state of the application is determined by the totality of all its data. And the more data in an application, the more difficult it is to manage it, the more unpredictable a situation may arise when something goes wrong.
Imagine a simple class with three boolean flags. To ensure that you cover all the scenarios for combining these flags, you need 2 Âł scenarios. It is necessary to cover eight scenarios with guarantee, to say that I process all combinations of flags exactly. If you add another flag, it increases proportionally.
We encountered a similar problem. It seemed to be a simple task, but as we developed and worked on it, we began to realize that something was going wrong. I will tell on the example of the features that we launched. It is called removing local photos. The idea is that the user ships some data to the cloud automatically. Most likely, this is a photo and video that he shot on his phone. It turns out that the files seem to be in the cloud. Why take up precious space on your phone when you can delete these photos?
Designers have drawn such a concept. It’s just a dialogue, he has a headline where the amount of space we can free up is drawn, the text of the message and a tick that there are two cleaning modes: delete all photos that the user has uploaded, or only those older than one month.
We looked - there is nothing complicated. Dialogue, two TextView, checkbox, buttons. But when we started working on this problem in detail, we realized that getting data about how many files we can delete is a long-term task. Therefore, we must show the user a kind of stub. This is pseudocode, in real life it looks different, but the meaning is the same.
We check some state, check that we are calculating, and draw the “Wait” stub.
When the calculations are over, we have several options for what to display to the user. For example, the number of files we can delete is zero. In this case, we draw the user a message that there is nothing to delete, so come next time. Then designers come to us and say that we have to distinguish between situations where the user has already cleaned the files or did not clear anything, nothing has loaded. Therefore, there is another condition that we are waiting for autoload and draw another message to it.
Then there are situations when something has worked, and for example, the user has a tick not to delete fresh files. In this case, there are two options too. Either the files can be cleared or the files cannot be cleared, that is, it has already cleared all the files, so we warn you that you have already deleted all the fresh files.
There is one more condition when we can really remove something. Ticked off, and there is an option that you can remove something. You look at this code, and it seems that something is wrong. I have not listed everything yet, we have a check of permishins, because without them, nothing works, we cannot touch the files on the card, plus we must check that the user basically has autoloading, because features are useless without autoloading, that we clean. And a few more conditions. And damn, it seems such a simple thing, but so many problems arose because of it.
And obviously, immediately there are several problems. First of all this code is unreadable. There is a certain pseudocode depicted, but in a real project it is smeared over different functions, pieces of code, it is not so easy to perceive by eye. Support for this code is also quite complicated. Especially when you come to a new project, you are told that you need to make such a feature, you add some condition, check the positive scenario, everything works, but then testers come and say that under certain conditions everything is broken. This happens because you simply did not take into account any scenarios.
Plus, it is redundant in the sense that since we have a large branch of conditions, we must check all conditions that do not suit us in advance. They are negative in advance, but since they are written with such branches, we are obliged to check them. The fact is that in the example I have some checkboxes boolean, but in practice you may have function calls that go somewhere deep, dig into the database. Anything can happen, because of redundancy there will be additional brakes.
And the saddest thing is that some unexpected behavior that was missed at the testing stage, nothing happened there, but somewhere in the production the user did nothing at best, some UI curve, and at worst - fell or the data was lost . Just the application behaved inconsistently.
How to solve this problem? With the power of the state machine.
The main task that the state machine copes with is that it takes a big complex task and breaks it up into small discrete states with which it is easier to interact and control. After sitting, thinking, as we are trying to do something MVP, how to tie our state to all of this? We have come to something like this. Who read the book GOF, this is a classic pattern-state, just what is called a context there, I called it a state-ouner, and in fact it is a presenter. A presenter has this state, can switch them, and can still provide some data to our states, if they want to know something, for example, the size of files or they want to request an asynchronous request, select.
Nothing here is superb, the next slide is more important.
This is where you start development when you start making a state machine. You sit at your computer or somewhere at the table, and on a piece of paper, or in special tools, draw a state diagram. There is nothing complicated either, but this stage has a lot of advantages. First, at an early stage, you can immediately detect any inconsistencies in business logic. Your products can come in, express your desire, everything seems to be fine, but when you start writing code, you realize that something is not fitting. I think everyone had this situation. But when you make a diagram, you can see at an early stage that something does not fit. It is drawn quite simply, there are special tools like PlantUML, in which you don’t even need to be able to draw, you must be able to write pseudo-code, and it generates graphs.
This is how our diagram looks like, which describes the state of this dialogue. There are several states and the logic of the transition between them.
Let's go to the code. The State itself, nothing important here, the main thing is that it has three methods: onEnter, which, upon entry, calls primarily invalidateView. What is it done for? So that as soon as we arrive at the state, the UI is updated. Plus there is an invalidateView method that we overload if we need to do something with the UI, and an onExit method in which we can do something if we exit the state.
StateOwner. An interface that provides the ability to flip through states. As we found out, this will be the future presenter. And these are the methods that give additional access to the data. If any data is rummaged between states, we can keep it in the presenter, and send it through this interface. In this case, we can give the size of the files that we can clear, and provide the opportunity to make some kind of request. We have passed to the state, we want to request something, and we can call the method through the StateOwner.
Another such utility is that it can also return a link to the view. This is done so that if you have a state and some data arrives, you do not want to switch to a new state, it is just redundant, you can directly update the view, the text. We use this to update the number of digits that the user sees when he looks at the dialogue. We are downloading files in the runtime, he looks at the dialogue, and the numbers are updated. We are not moving to a new state; we are simply updating the current View.
There is a standard MVP, everything should be extremely simple, no logic, simple methods that draw something. I stick to this concept. There should be no logic, at least some action. Purely take some Text View, change it, no more.
Presenter. There are more interesting things. First of all, we can fumble data through it for some states, we have two variables, annotated with State. Who used Icepick, is familiar with it. We do not write with our hands serialization in Partible, we use a ready-made library.
The following is the initial state. It is always useful to set the initial state, even if it does nothing. The usefulness is that you do not need to do checks for null, but if we say that it can do something. For example, you need to do something once during the life cycle of your application, when we start, you need to perform the procedure once, and never do it again. When exiting the initial state, we can always do something like this, and we never return to this state. Type so drawn state diagram. Although who knows who draws, maybe you can come back.
I am in favor of having as few as possible checks for Null and so on, so here I keep a link to a simple implementation of the view. We don’t need to synchronize anything, just at some moment when detach happens, we replace the view with an empty one, and the presenter can switch to states in some place, think that there is a view, it updates it, but in fact it works with empty implementation.
There are a few more methods to maintain the state, we want to experience a coup Activity, in this case it is all done through the designer. Everything is a little more complicated, here is an exaggerated example.
It is necessary to forward saveState, if someone worked with similar libraries, everything is rather trivial. You can write with your hands. And two methods are very important: attach, called in onStart, and detach, called in onStop.
What is their importance? Initially, we planned to attack and get into detail in onCreateView, onDestroyView, but this was not quite enough. If you have a View, you may have the text updated, or the dialog fragment may appear. And if you don’t get into onStop, and then try to show a fragment, you’ll catch the well-known exception that you cannot commit a transaction when we have a state. Either use commit state loss, or do not do so. Therefore, we are working in onStop, while there the presenter will continue to work, switch states, catch events. And at that moment, when the start happens, we will raise the view attached event, and the presenter will update the UI to match the current state.
There is a release method, it is usually called in onDestroy, do the details and release the resources additionally.
Another important setState method. Since we are planning to change the UI in onEnter and onExit, there is a check for the main thread. This creates a restriction for us that we do not do anything heavy here, all requests must be either to the UI, or must be asynchronous. The advantage of this place is that here we can log in and out of the state, it is very useful when debugging, for example, when something goes wrong, you can look at how the system was flipped and understand what was wrong.
A couple of examples of states. There is a state of Initial, just triggers the calculation of how much space you need to free up at the moment when the view is available. This will happen after onStart. As soon as onStart happens, we move to a new state, and the system starts to request data.
An example of a Calculating state, we state size of files from stateOwner, it somehow climbs into the database, and then we still have inValidateView, we update the current user UI. And viewAttached will be called if the view is zattachitsya again. If we were in the background, Calculating was in the background, we again return to our Activity, this method will be called and updates all the data.
An example of an event, we asked how many files to release from stateOwner, and it calls the filesSizeUpdated method. Here I was too lazy, it was possible to write three separate methods, such as updated, so many old files, how to separate different events. But you have to understand, sometime it will be difficult for you, sometime much easier. It is not necessary to overlook the fact that each event is a separate method. You can easily do with a simple if, I don’t see anything terrible in it.
I see several potential improvements. I don’t like that we have to use these methods, such as onStart, on Stop, onCreate, onSave and so on. You can attach to the Lifecycle, but it is not clear how to be with saveState. There is an idea, for example, to make a presenter a fragment. Why not? A fragment without a UI that catches the life cycle, and in general we will not need anything, everything will fly to us.
Another interesting point: this presenter is re-created every time, and if you have big data stored in the presenter, you went to the database, hold the huge cursor, then it is unacceptable to ask each time you rotate the screen. Therefore, you can cache a presenter, as does, for example, the ViewModule from the Architecture Components, make some fragment that will hold the cache of presenters and return them for each view.
You can use the tabular method of specifying the state machines, because the state we use has one significant disadvantage: as soon as you need to add one method to a new event, you must add implementation to all heirs. At least empty. Or do it in basic condition. This is not very convenient. So the tabular way to set state machines is used in all libraries - if you search on GitHub for the word FSM, you will find a large number of libraries that provide you with a certain builder, where you specify the initial state, event and final state. Expanding and maintaining such a state machine is much easier.
Another interesting point: if you use the pattern state, if your state machine starts to grow, you will most likely have some events to be handled the same way so that the code does not copy and paste, you create a basic state. The more events, the more basic states begin to appear, the hierarchy grows, and something goes wrong.
As we know, inheritance should be replaced by delegation, and hierarchical state machines help solve this problem. You have a state that does not depend on the level of inheritance - just build a tree of states that pass the handler above. You can also read separately, a very useful thing. In Android, for example, hierarchical state machines are used in WatchDog Wi-Fi, which monitors the status of the network, there they are, right in the Android source.
Last but not least. How can this be tested? First of all, you can test deterministic states. There is a separate state, we create an instance, pull the onEnter method and see that the corresponding values ​​have been called for the view. Thus we validate that our state correctly updates the View. If your View does not do anything serious, then, most likely, you will cover a huge number of scenarios.
You can lock some methods with a function that returns the size, call another event after onEnter and see how a particular state responds to specific events. In this case, when the filesSizeUpdated event occurs and when AllFilesSize is greater than zero, we need to go to the new CleanAllFiles state. With the help of the layout, we all check it out.
And the last - we can test the system entirely. We construct a state, send an event to it and check how the system behaves. We have three stages of testing. , UI, , , , .