From copy paste to components: reusing code in different applications





Badoo develops several applications, and each of them is a separate product with its own characteristics, management, product and engineering teams. But we all work together in the same office and solve similar problems.



The development of each project took place in its own way. The code base was influenced not only by different time frames and product solutions, but also by the vision of the developers. In the end, we noticed that projects have the same functionality, which is fundamentally different in implementation.



Then we decided to come to a structure that would give us the opportunity to reuse features between applications. Now, instead of developing functionality in individual projects, we create common components that integrate into all products. If you are interested in how we came to this, welcome to cat.



But first, let's dwell on the problems, the solution of which led to the creation of common components. There were several of them:









This article is a text version of my report with AppsConf 2019 , which can be viewed here .



Problem: copy paste



Some time ago, when the trees were fluffier, the grass was greener, and I was a year younger, we often had the following situation.



There is a developer, let's call him Lesha. He makes a cool module for his task, tells his colleagues about it and puts it in the repository for his application, where he uses it.



The problem is that all our applications are in different repositories.







Developer Andrey at this time is just working on another application in another repository. He wants to use this module in his task, which is suspiciously similar to the one that Lesha was engaged in. But a problem arises: the process of reusing code is completely debugged.



In this situation, Andrei will either write his decision (which happens in 80% of cases) or copy-paste the solution of Lyosha and change everything in it so that it fits his application, task or mood.







After that, Lesha can update his module by adding changes to his code for his task. He does not know about another version and will only update his repository.



This situation brings several problems.



Firstly, we have several applications, each with its own development history. When working on each application, the product team often created solutions that are difficult to bring to a single structure.



Secondly, separate teams are involved in projects, which communicate poorly with each other and, therefore, rarely inform each other about updates / reuse of one or another module.



Thirdly, the application architecture is very different: from MVP to MVI, from god activity to single activity.



Well, the “highlight of the program”: applications are in different repositories, each with its own processes.



At the beginning of the fight against these problems, we set the ultimate goal: to reuse our best practices (both logic and UI) between all applications.



Decisions: we establish processes



Of the above problems, two are related to the processes:



  1. Two repositories that shared projects with an impenetrable wall.

  2. Separate teams without established communication and different requirements of product application teams.



Let's start with the first one: we are dealing with two repositories with the same module version. Theoretically, we could use git-subtree or similar solutions and put common project modules into separate repositories.







The problem occurs during modification. Unlike open-source projects, which have a stable API and are distributed through external sources, changes often occur in internal components that break everything. When using subtree, each such migration becomes a pain.



My colleagues from the iOS team have similar experience, and it turned out to be not very successful, as Anton Schukin talked about at the Mobius conference last year.



Having studied and comprehended their experience, we switched to a single repository. All Android applications now lie in one place, which gives us certain benefits:





Of course, this solution also has disadvantages. We have a huge project that is sometimes not subject to IDE and Gradle. The Load / Unload modules in Android Studio could partially solve the problem, but they are difficult to use if you need to work on all applications at the same time and often switch.



The second problem - interaction between teams - consisted of several parts:





To solve it, we formed teams that are engaged in the implementation of certain functionality in each application: for example, chat or registration. In addition to development, they are also responsible for integrating these components into the application.



Product teams already have existing components in their hands, improving and customizing them to the needs of a particular project.



Thus, now the creation of a reusable component is part of the process for the entire company, from the stage of the idea to the start of production.



Solutions: streamlining architecture



Our next step towards reuse was to streamline the architecture. Why did we do this?



Our code base carries the historical legacy of several years of development. Along with time and people, approaches changed. So we found ourselves in a situation with a whole zoo of architectures, which resulted in the following problems:



  1. Integration of common modules was almost slower than writing new ones. In addition to the features of the functional, it was necessary to put up with the structure of both the component and the application.

  2. Developers who had to switch between applications very often spent a lot of time mastering new approaches.

  3. Often, wrappers were written from one approach to another, which amounted to half the code in the module integration.



In the end, we settled on the MVI approach, which we structured in our MVICore library ( GitHub ). We were especially interested in one of its features - atomic state updates, which always guarantee validity. We went a little further and combined the states of the logical and presentation layers, reducing fragmentation. Thus, we come to a structure where the only entity is responsible for the logic, and view only displays the model created from the state.







Separation of responsibilities occurs through the transformation of models between levels. Thanks to this, we get a bonus in the form of re-usability. We connect the elements from the outside, that is, each of them does not suspect that the other exists - they simply give away some models and react to what comes to them. This allows you to pull out components and use them elsewhere by writing adapters for their models.



Let's look at an example of a simple screen how it looks in reality.







We use the basic RxJava interfaces to indicate the types with which the element works. Input is denoted by the interface Consumer <T>, output - ObservableSource <T>.



// input = Consumer<ViewModel> // output = ObservableSource<Event> class View( val events: PublishRelay<Event> ): ObservableSource<Event> by events, Consumer<ViewModel> { val button: Button val textView: TextView init { button.setOnClickListener { events.accept(Event.ButtonClick) } } override fun accept(model: ViewModel) { textView.text = model.text } }
      
      





Using these interfaces, we can express View as Consumer <ViewModel> and ObservableSource <Event>. Note that the ViewModel only contains the state of the screen and has little to do with MVVM. Having received the model, we can show the data from it, and when we click on the button, we send the event, which is transmitted outside.



 // input = Consumer<Wish> // output = ObservableSource<State> class Feature: ReducerFeature<Wish, State>( initialState = State(counter = 0), reducer = ReducerImpl() ) { class ReducerImpl: Reducer<Wish, State> { override fun invoke(state: State, wish: Wish) = when (wish) { is Increment -> state.copy(counter = state.counter + 1) } } }
      
      





Feature already implements ObservableSource and Consumer for us; we need to transfer there the initial state (counter equal to 0) and indicate how to change this state.



After the transfer of Wish, Reducer is called, which creates a new one based on the last state. In addition to Reducer, the logic can be described by other components. You can learn more about them here .



After creating the two elements, it remains for us to connect them.





 val eventToWish: (Event) -> Wish = { when (it) { is ButtonClick -> Increment } } val stateToModel: (State) -> ViewModel = { ViewModel(text = state.counter.toString()) } Binder().apply { bind(view to feature using eventToWish) bind(feature to view using stateToModel) }
      
      





First, we indicate how we transform an element of one type into another. So, ButtonClick becomes Increment, and the counter field from State goes into text.



Now we can create each of the chains with the desired transformation. For this we use Binder. It allows you to create relationships between ObservableSource and Consumer, observing the life cycle. And all this with a nice syntax. This type of connection leads us to a flexible system that allows us to pull out and use elements individually.



MVICore-elements work quite well with our “zoo” of architectures after writing wrappers from ObservableSource and Consumer. For example, we can wrap Use Case methods from Clean Architecture in Wish / State and use the chain instead of Feature.







Component



Finally, we move on to the components. What are they like?



Consider the screen in the application and divide it into logical parts.







It can be distinguished:





Each of these parts is the very component that can be reused in a completely different context. So, the Instagram section can become part of profile editing in another application.







In the general case, a component is several View, logic elements and nested components inside, united by a common functionality. And immediately the question arises: how to assemble them into a supported structure?



The first problem that we encountered is that MVICore helps to create and bind elements, but does not offer a common structure. When reusing elements from a common module, it is not clear where to put these pieces together: inside the common part or on the application side?



In the general case, we definitely do not want to give the application scattered pieces. Ideally, we strive for some kind of structure that will allow us to obtain dependencies and assemble the component as a whole with the desired life cycle.



Initially, we divided the components into screens. The connection of the elements took place next to the creation of DI-containers for activity or fragment. These containers already know about all the dependencies, have access to the View and the life cycle.



 object SomeScopedComponent : ScopedComponent<SomeComponent>() { override fun create(): SomeComponent { return DaggerSomeComponent.builder() .build() } override fun SomeComponent.subscribe(): Array<Disposable> = arrayOf( Binder().apply { bind(feature().news to otherFeature()) bind(feature() to view()) } ) }
      
      





Problems began in two places at once:



  1. DI started working with logic, which led to the description of the entire component in one class.

  2. Since the container is attached to an Activity or Fragment and describes at least the whole screen, there are a lot of elements on such a screen / container, which translates into a huge amount of code to connect all the dependencies of this screen.



Solving the problems in order, we started by putting the logic in a separate component. So, we can collect all Feature inside this component and communicate with View through input and output. From the point of view of the interface, this looks like a regular MVICore element, but at the same time it is created from several others.







Having solved this problem, we shared responsibility for connecting the elements. But we still shared the components on the screens, which was clearly not at hand for us, resulting in a huge number of dependencies in one place.



 @Scope internal class ComponentImpl @Inject constructor( private val params: ScreenParams, news: NewsRelay, @OnDisposeAction onDisposeAction: () -> Unit, globalFeature: GlobalFeature, conversationControlFeature: ConversationControlFeature, messageSyncFeature: MessageSyncFeature, conversationInfoFeature: ConversationInfoFeature, conversationPromoFeature: ConversationPromoFeature, messagesFeature: MessagesFeature, messageActionFeature: MessageActionFeature, initialScreenFeature: InitialScreenFeature, initialScreenExplanationFeature: InitialScreenExplanationFeature?, errorFeature: ErrorFeature, conversationInputFeature: ConversationInputFeature, sendRegularFeature: SendRegularFeature, sendContactForCreditsFeature: SendContactForCreditsFeature, screenEventTrackingFeature: ScreenEventTrackingFeature, messageReadFeature: MessageReadFeature?, messageTimeFeature: MessageTimeFeature?, photoGalleryFeature: PhotoGalleryFeature?, onlineStatusFeature: OnlineStatusFeature?, favouritesFeature: FavouritesFeature?, isTypingFeature: IsTypingFeature?, giftStoreFeature: GiftStoreFeature?, messageSelectionFeature: MessageSelectionFeature?, reportingFeature: ReportingFeature?, takePhotoFeature: TakePhotoFeature?, giphyFeature: GiphyFeature, goodOpenersFeature: GoodOpenersFeature?, matchExpirationFeature: MatchExpirationFeature, private val pushIntegration: PushIntegration ) : AbstractMviComponent<UiEvent, States>(
      
      





The correct solution in this situation is to break the component. As we saw above, each screen consists of many logical elements that we can divide into independent parts.



After a little reflection, we came to a tree structure and, naively building it from existing components, we got this scheme:







Of course, maintaining synchronization of two trees (from View and from logic) is almost impossible. However, if the component is responsible for displaying its View, we can simplify this scheme. Having studied the already created solutions, we rethought our approach, relying on Uber's RIBs.







The ideas behind this approach are very similar to the basics of MVICore. RIB is a kind of “black box”, communication with which occurs through a strictly defined interface from dependencies (namely, input and output). Despite the apparent complexity of supporting such an interface in a fast iterative product, we get great opportunities for reusing code.



Thus, in comparison with previous iterations, we get:





Of course, this is far from all. The repository on GitHub contains a more detailed and up-to-date description.



And here we have a perfect world. It has components from which we can build a fully reusable tree.



But we live in an imperfect world.



Welcome to reality!



In an imperfect world, there are a bunch of things that we have to put up with. We are worried about the following:





The complexity of solutions increases exponentially, as each application adds something of its own to common components.



Consider the registration process as an example of a common component that integrates into applications. In general, registration is a chain of screens with actions that affect the entire flow. Each application has different screens and its own UI. The ultimate goal is to make a flexible reusable component, which will also help us solve the problems from the list above.







Miscellaneous requirements



Each application has its own unique registration variations from both the logic and the UI. Therefore, we begin to generalize the functionality in the component with a minimum: by downloading data and routing the entire flow.







Such a container transfers data from the server to the application, which is converted into a finished screen with logic. The only requirement is that screens passed to such a container must satisfy dependencies in order to interact with the logic of the entire flow.



Having done this trick with a couple of applications, we noticed that the logic of the screens is almost the same. In an ideal world, we would create common logic by customizing the View. The question is how to customize them.



As you can recall from the description of MVICore, both View and Feature are based on the interface from ObservableSource and Consumer. Using them as an abstraction, we can replace the implementation without changing the main parts.







So we reuse the logic by dividing the UI. As a result, support becomes much more convenient.



Support



Consider the A / B test for the variation of visual elements. In this case, our logic does not change, which allows us to substitute another View implementation for the existing interface from ObservableSource and Consumer.







Of course, sometimes new requirements contradict already written logic. In this case, we can always return to the original scheme, where the application supplies the entire screen. For us, this is a kind of “black box”, and it does not matter to the container what it is transferred to it, as long as its interface is respected.



Integration



As practice shows, most applications use Activity as the basic units, the means of communication between which have long been known. All we had to do was learn how to wrap components in Activity and pass data through input and output. As it turned out, this approach also works well with fragments.



For single activity applications, nothing changes much. Almost all frameworks offer their basic elements in which RIB components allow themselves to be wrapped.



Eventually



Having gone through these stages, we have significantly increased the percentage of code reuse between the projects of our company. At the moment, the number of components is approaching 100, and most of them implement functionality for several applications at once.



Our experience shows that:





My colleague Zsolt Kocsi previously wrote about MVICore and the ideas behind it. I highly recommend reading his articles, which we have translated on our blog ( 1 , 2 , 3 ).



About RIBs you can read the original article from Uber . And for practical knowledge, I recommend taking a few lessons from us (in English).



All Articles