I came to Tinkoff a couple of years ago, on a new project called Clients and Projects , which was just starting up then.
Now I don’t remember my feelings from the new architecture for me then. But I remember for sure: it was unusual that Rx is used somewhere else, outside of the usual trips to the network and to the base. Now that this architecture has already passed some evolutionary path of development, I want to finally talk about what happened and what came to.
In my opinion, all currently popular architectures - MVP, MVVM, and even MVI - have long been in the arena and not always well-deserved. Don't they have flaws? I see a lot of them. At our place, we decided that it’s enough to endure this, and (re) invented a new, asynchronous architecture.
I will briefly describe what I do not like about current architectures. Some points may be controversial. Perhaps you have never encountered this, you write perfect and generally Jedi programming. Then forgive me, a sinner.
So my pain is:
- Huge Presenter / ViewModel.
- A huge amount of switch-case in MVI.
- Inability to reuse parts of Presenter / ViewModel and, as a result, the need to duplicate code.
- Heaps of mutable variables that can be modified from anywhere. Accordingly, such code is difficult to maintain and modify.
- Not decomposed screen update.
- It's hard to write tests.
Issue
At every moment in time, the application has a certain state that defines its behavior and what the user sees. This state includes all values of variables - from simple flags to individual objects. Each of these variables lives its own life and is controlled by different parts of the code. You can determine the current state of the application only by checking them all, one after another.
An article on modern Kotlin MVI architecture
Chapter 1. Evolution is our everything
Initially, we wrote on MVP, but a little mutated. It was a mixture of MVP and MVI. There were entities from MVP in the form of a presenter and View interface:
interface NewTaskView { val newTaskAction: Observable<NewTaskAction> val taskNameChangeAction: Observable<String> val onChangeState: Consumer<SomeViewState> }
Already here you can notice the catch: View here is very far from the canons of MVP. There was a method in the presenter:
fun bind(view: SomeView): Disposable
Outside, an interface implementation was passed that reactively subscribed to UI changes. And it already smacks of MVI!
Further more. In Presenter, various interactors were created and subscribed to the View changes, but they did not call UI methods directly, but returned some global State, in which there were all possible screen states:
compositeDisposable.add( Observable.merge(firstAction, secondAction) .observeOn(AndroidSchedulers.mainThread()) .subscribe(view.onChangeState)) return compositeDisposable
class SomeViewState(val progress: Boolean? = null, val error: Throwable? = null, val errorMessage: String? = error?.message, val result: TaskUi? = null)
Activity was the descendant of SomeViewStateMachine interface:
interface SomeViewStateMachine { fun toSuccess(task: SomeUiModel) fun toError(error: String?) fun toProgress() fun changeSomeButton(buttonEnabled: Boolean) }
When the user clicked on something on the screen, an event came to the presenter and he created a new model, which was drawn by a special class:
class SomeViewStateResolver(private val stateMachine: SomeViewStateMachine) : Consumer<SomeViewState> { override fun accept(stateUpdate: SomeViewState) { if (stateUpdate.result != null) { stateMachine.toSuccess(stateUpdate.result) } else if (stateUpdate.error != null && stateUpdate.progress == false) { stateMachine.toError(stateUpdate.errorMessage) } else if (stateUpdate.progress == true) { stateMachine.toProgress() } else if (stateUpdate.someButtonEnabled != null) { stateMachine.changeSomeButton(stateUpdate.someButtonEnabled) } } }
Agree, some strange MVP, and even far from MVI. Looking for inspiration.
Chapter 2. Redux
Talking about his problems with other developers, our (then still) leader Sergey Boishtyan learned about Redux .
After watching Dorfman’s talk about all the architectures and playing with Redux , we decided to use it to upgrade our architecture.
But first, let's take a closer look at architecture and look at its pros and cons.
Action
Describes the action.
Actioncreator
He is like a systems analyst: formats, complements the customer requirements specification so that programmers understand him.
When the user clicks on the screen, ActionsCreator forms an Action that goes to middleware (some kind of business logic). Business logic gives us new data that a particular Reducer receives and draws.
If you look at the picture again, you may notice an object such as Store. Store stores Reducers. That is, we see that the front-end brothers - unfortunate brothers - have guessed that one large object can be sawn into many small ones, each of which will be responsible for its own part of the screen. And this is just a wonderful thought!
Sample code for simple ActionCreators (careful, JavaScript!):
export function addTodo(text) { return { type: ADD_TODO, text } } export function toggleTodo(index) { return { type: TOGGLE_TODO, index } } export function setVisibilityFilter(filter) { return { type: SET_VISIBILITY_FILTER, filter } }
Reducer
Actions describes the fact that something happened, but does not indicate how the state of the application should change in response, this is work for Reducer.
In short, Reducer knows how to decomposedly refresh the / view screen.
Pros:
- Decomposed screen update.
- Unidirectional data stream.
Minuses:
- Favorite switch again.
function todoApp(state = initialState, action) { switch (action.type) { case SET_VISIBILITY_FILTER: return Object.assign({}, state, { visibilityFilter: action.filter }) case ADD_TODO: return Object.assign({}, state, { todos: [ ...state.todos, { text: action.text, completed: false } ] }) default: return state }
- A bunch of state objects.
- Separation of logic into ActionCreator and Reducer.
Yes, it seemed to us that the separation of ActionCreator and Reducer is not the best option for connecting the model and the screen, because writing instanceof (is) is a bad approach. And here we invented OUR architecture!
Chapter 3. EBA
What is Action and ActionCreator in the context of EBA:
typealias Action = () -> Unit typealias ActionMapper<T> = (T) -> Action interface ActionCreator<T> : (T) -> (Observable<Action>)
Yes, half of the architecture is typealias and an interface. Simplicity equals elegance!
Action is needed in order to call something without transmitting any data. Since the ActionCreator returns an Observable, we had to wrap the Action in another lambda to transmit some data. And so it turned out ActionMapper - a typed Action through which we can pass what we need to update the screen / view.
Basic postulates:
With the first paragraph, everything is clear: so that there is no hell from incomprehensible cross-updates, we agreed that one ActionCreator can update only its part of the screen. If it is a list, it updates only the list, if the button only it.
But, one wonders, why did Dagger not please us? I tell you.
A typical story is when an abstract Sergey aka dagger master aka "What does this annotation do?" Is on the project.
It turns out that if you experimented with a dagger, you have to explain each time to each new (and not only new) developer. Or maybe you yourself already forgot what this annotation does, and you go google.
All this greatly complicates the process of creating features without introducing much convenience. Therefore, we decided that we will create the things we need with our hands, so it will be faster to assemble, because there is no code generation. Yes, we will spend an extra five minutes writing all the dependencies with our hands, but we will save a lot of time on compilation. Yes, we have not everywhere abandoned the dagger, it is used on a global level, it creates some common things, but we write them in Java for better optimization, so as not to attract kapt.
Architecture scheme :
Component is an analogue of the same component from Dagger, only without Dagger. His task is to create a binder. Binder ties ActionCreators together. From View to Binder Events come about what happened, and from Binder to View, Actions are sent that update the screen.
Actioncreator
Now let's see what kind of thing this is - ActionCreator. In the simplest case, it simply processes the action unidirectionally. Suppose there is such a scenario: the user clicked on the "Create a task" button. Another screen should open, where we will describe it, without any additional requests.
To do this, we simply subscribe to the button using RxBinding from our beloved Jake and wait for the user to click on it. As soon as the click occurs, Binder will send the Event to a specific ActionCreator, which will call our Action, which will open a new screen for us. Note that there were no switches. Next, I will show in the code why this is so.
If we suddenly need to go to the network or the database, we make these requests right there, but through the interactors that we passed to the ActionCreator constructor via the interface for calling them:
Disclaimer: the formatting of the code is not quite the same here, I have its rules for the article so that the code is well read.
class LoadItemsActionCreator( private val getItems: () -> Observable<List<ViewTyped>>, private val showLoadedItems: ActionMapper<DiffResult<ViewTyped>>, private val diffCalculator: DiffCalculator<ViewTyped>, private val errorItem: ErrorView, private val emptyItem: ViewTyped? = null) : ActionOnEvent
By the words "by the interface of their call" I meant exactly how getItems is declared (here ViewTyped is our interface for working with lists). By the way, we have reused this ActionCreator in eight different parts of the application, because it is written as versatile as possible.
Since events are of a reactive nature, we can assemble the chain by adding other operators there, for example startWith (showLoadingAction) to show loading, and onErrorReturn (errorAction) to show the state of the screen with an error.
And all this is reactive!
Example
class AboutFragment : CompositionFragment(R.layout.fragment_about) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val component = AboutComponent( setVersionName = { { appVersion.text = it } }, openPdfAction = { (url, name) -> { openPdf(url, name) } }) val events = AboutEventsImpl( bindEvent = bindEvent, openPolicyPrivacyEvent = confidentialityPolicy.clicks(), openProcessingPersDataEvent = personalDataProtection.clicks(), unbindEvent = unBindEvent) component.binder().bind(events) }
Let’s finally look at the architecture using code as an example. To start, I chose one of the simplest screens - about the application, because it is a static screen.
Consider creating a component:
val component = AboutComponent( setVersionName = { { appVersion.text = it } }, openPdfAction = { (url, name) -> { openPdf(url, name) } } )
Component arguments — Actions / ActionMappers — help associate the View with ActionCreators. In ActionMapper'e setVersionName we pass the project version and assign this value to the text on the screen. In openPdfAction, a pair of a document link and a name to open the next screen where the user can read this document.
Here is the component itself:
class AboutComponent( private val setVersionName: ActionMapper<String>, private val openPdfAction: ActionMapper<Pair<String, String>>) { fun binder(): AboutEventsBinder { val openPolicyPrivacy = OpenPdfActionCreator(openPdfAction, someUrlString) val openProcessingPersonalData = OpenPdfActionCreator(openPdfAction, anotherUrlString) val setVersionName = setVersionName.toSimpleActionCreator( moreComponent::currentVersionName ) return AboutEventsBinder(setVersionName, openPolicyPrivacy, openProcessingPersonalData) } }
Let me remind you that:
typealias Action = () -> Unit typealias ActionMapper<T> = (T) -> Action
OK, let's move on.
fun binder(): AboutEventsBinder
Let's take a look at AboutEventsBinder in more detail.
class AboutEventsBinder(private val setVersionName: ActionOnEvent, private val openPolicyPrivacy: ActionOnEvent, private val openProcessingPersonalData: ActionOnEvent) : BaseEventsBinder<AboutEvents>() { override fun bindInternal(events: AboutEvents): Observable<Action> { return Observable.merge( setVersionName(events.bindEvent), openPolicyPrivacy(events.openPolicyPrivacyEvent), openProcessingPersonalData(events.openProcessingPersDataEvent)) } }
ActionOnEvent is another typealias, so as not to write every time.
ActionCreator<Observable<*>>
In AboutEventsBinder, we pass ActionCreators and, invoking them, bind to a specific event. But, to understand how all this is connected, let's look at the base class - BaseEventsBinder.
abstract class BaseEventsBinder<in EVENTS : BaseEvents>( private val uiScheduler: Scheduler = AndroidSchedulers.mainThread() ) { fun bind(events: EVENTS) { bindInternal(events).observeOn(uiScheduler) .takeUntil(events.unbindEvent) .subscribe(Action::invoke) } protected abstract fun bindInternal(events: EVENTS): Observable<Action> }
We see the familiar bindInternal method, which we redefined in the heir. Now consider the bind method. All the magic is here. We accept the inheritor of the BaseEvents interface, pass it to bindInternal to connect Events and Actions. Once we say that whatever comes, we execute on the ui-stream and subscribe. We also see an interesting hack - takeUntil.
interface BaseEvents { val unbindEvent: EventObservable }
Having defined the unbindEvent field in BaseEvents to control unsubscription, we are obliged to implement it in all the heirs. This wonderful field allows you to unsubscribe from the chain automatically as soon as this event is completed. It's just great! Now you can not follow and do not worry about the life cycle and sleep peacefully.
val openPolicyPrivacy = OpenPdfActionCreator(openPdfAction, policyPrivacyUrl) val openProcessingPersonalData = OpenPdfActionCreator(openPdfAction, personalDataUrl)
Let's go back to the component. And here you can already see the method of reuse. We wrote one class that can open the pdf viewing screen, and it doesn't matter to us what url it is. No code duplication.
class OpenPdfActionCreator( private val openPdfAction: ActionMapper<Pair<String, String>>, private val pdfUrl: String) : ActionOnEvent { override fun invoke(event: EventObservable): Observable<Action> { return event.map { openPdfAction(pdfUrl to pdfUrl.substringAfterLast(FILE_NAME_DELIMITER)) } } }
The ActionCreator code is also as simple as possible, here we just perform some string manipulations.
Let's go back to the component and consider the following ActionCreator:
setVersionName.toSimpleActionCreator(moreComponent::currentVersionName)
Once we became too lazy to write the same and inherently simple ActionCreators. We used the power of Kotlin and wrote extension'y. For example, in this case, we just needed to pass a static string to ActionMapper.
fun <R> ActionMapper<R>.toSimpleActionCreator( mapper: () -> R): ActionCreator<Observable<*>> { return object : ActionCreator<Observable<*>> { override fun invoke(event: Observable<*>): Observable<Action> { return event.map { this@toSimpleActionCreator(mapper()) } } } }
There are cases when we don’t need to transmit anything at all, but only call some Action - for example, to open the following screen:
fun Action.toActionCreator(): ActionOnEvent { return object : ActionOnEvent { override fun invoke(event: EventObservable): Observable<Action> { return event.map { this@toActionCreator } } } }
So, with the component over, go back to the fragment:
val events = AboutEventsImpl( bindEvent = bindEvent, openPolicyPrivacyEvent = confidentialityPolicy.throttleFirstClicks(), openProcessingPersDataEvent = personalDataProtection.throttleFirstClicks(), unbindEvent = unBindEvent)
Here we see the creation of a class responsible for receiving events from the user. And unbind and bind are just screen life cycle events that we pick up using Trello's Navi library.
fun <T> NaviComponent.observe(event: Event<T>): Observable<T> = RxNavi.observe(this, event) val unBindEvent: Observable<*> = observe(Event.DESTROY_VIEW) val bindEvent: Observable<*> = Observable.just(true) val bindEvent = observe(Event.POST_CREATE)
The Events interface describes the events of a particular screen, plus it must inherit BaseEvents. The following is always the implementation of the interface. In this case, the events turned out to be one-on-one with those that come from the screen, but it happens that you need to control two events.
For example, events of screen loading upon opening and reloading in case of an error should be combined into one - just loading the screen.
interface AboutEvents : BaseEvents { val bindEvent: EventObservable val openPolicyPrivacyEvent: EventObservable val openProcessingPersDataEvent: EventObservable } class AboutEventsImpl(override val bindEvent: EventObservable, override val openPolicyPrivacyEvent: EventObservable, override val openProcessingPersDataEvent: EventObservable, override val unbindEvent: EventObservable) : AboutEvents
We return to the fragment and combine everything together! We ask the component to create and return a binder to us, then we call the bind method on it, where we pass the object that watches the screen events.
component.binder().bind(events)
We have been writing a project on this architecture for about two years now. And there is no limit to the happiness of managers in the speed of feature sharing! They do not have time to come up with a new one, as we are already finishing the old. The architecture is very flexible and allows you to reuse a lot of code.
The disadvantage of this architecture can be called nonconservation of state. We do not have a whole model that describes the state of the screen, as in MVI, but we can handle it. How - see below.
Chapter 4. Bonus
I think everyone knows the problem of analytics: no one likes to write it, because it crawls through all layers and disfigures challenges. Some time ago, and we had to face it. But thanks to our architecture, a very beautiful implementation was obtained.
So, what was my idea: analytics usually leaves in response to user actions. And we just have a class that accumulates user actions. Ok, let's get started.
Step 1 We slightly change the BaseEventsBinder base class by wrapping events in trackAnalytics:
abstract class BaseEventsBinder<in EVENTS : BaseEvents>( private val trackAnalytics: TrackAnalytics<EVENTS> = EmptyAnalyticsTracker(), private val uiScheduler: Scheduler = AndroidSchedulers.mainThread()) { @SuppressLint("CheckResult") fun bind(events: EVENTS) { bindInternal(trackAnalytics(events)).observeOn(uiScheduler) .takeUntil(events.unbindEvent) .subscribe(Action::invoke) } protected abstract fun bindInternal(events: EVENTS): Observable<Action> }
Step 2 We create a stable implementation of the trackAnalytics variable in order to maintain backward compatibility and not break the heirs who do not need analytics yet:
interface TrackAnalytics<EVENTS : BaseEvents> { operator fun invoke(events: EVENTS): EVENTS } class EmptyAnalyticsTracker<EVENTS : BaseEvents> : TrackAnalytics<EVENTS> { override fun invoke(events: EVENTS): EVENTS = events }
Step 3 We write the implementation of the TrackAnalytics interface for the desired screen - for example, for the project list screen:
class TrackProjectsEvents : TrackAnalytics<ProjectsEvents> { override fun invoke(events: ProjectsEvents): ProjectsEvents { return object : ProjectsEvents by events { override val boardClickEvent = events.boardClickEvent.trackTypedEvent { allProjectsProjectClick(it.title) } override val openBoardCreationEvent = events.openBoardCreationEvent.trackEvent { allProjectsAddProjectClick() } override val openCardsSearchEvent = events.openCardsSearchEvent.trackEvent { allProjectsSearchBarClick() } } } }
Here we again use the power of Kotlin in the form of delegates. We already have an interface inheritor created by us - in this case ProjectsEvents. But for some events, you need to redefine how events go and add a binding around them with sending analytics. In fact, trackEvent is just doOnNext:
inline fun <T> Observable<T>.trackEvent(crossinline event: AnalyticsSpec.() -> Unit): Observable<T> = doOnNext { event(analyticsSpec) } inline fun <T> Observable<T>.trackTypedEvent(crossinline event: AnalyticsSpec.(T) -> Unit): Observable<T> = doOnNext { event(analyticsSpec, it) }
Step 4 It remains to transfer this to Binder. Since we construct it in the component, we have the opportunity, if you suddenly need to, to add additional dependencies to the constructor. Now the ProjectsEventsBinder constructor will look like this:
class ProjectsEventsBinder( private val loadItems: LoadItemsActionCreator, private val refreshBoards: ActionOnEvent, private val openBoard: ActionCreator<Observable<BoardId>>, private val openScreen: ActionOnEvent, private val openCardSearch: ActionOnEvent, trackAnalytics: TrackAnalytics<ProjectsEvents>) : BaseEventsBinder<ProjectsEvents>(trackAnalytics)
You can look at other examples on GitHub .
Questions and answers
No way. We block orientation. But we also use arguments / intent and save the OPENED_FROM_BACKSTACK variable there. And when designing Binder, we look at it. If it is false - load data from the network. If true - from the cache. This allows you to quickly recreate the screen.
For everyone who is against orientation blocking: try to test and deposit analytics on how often your users turn the phone over and how many are in a different orientation. The results may surprise.
I do not advise, but if you do not mind compiling time, you can create Component through a dagger. But we did not try.
All the same can be written in Java, just it will not look so beautiful.
If you like the article, the next part will be about how to write tests on such an architecture (it will become clear why so many interfaces are needed). Spoiler - writing is easy and you can write on all layers except the component, but you don’t need to test it, it just creates a binder object.
Thanks to colleagues from the Tinkoff Business mobile development team for their help writing this article.