3 Years of Android in Traveloka

It’s been a little bit over three years since I joined Traveloka as Android engineer. Since then the app has been through 35 minor releases, 25 major releases, 2 major architecture changes, and 1 rewrite from scratch. From having only 3 products in 2015, it now has more than 20 products. The team has also grown from 5 to over 30 Android engineers.

Home Screen changes over 3 years

Back in May 2015 when I first joined, there were only Flight, Hotel and Payment products. And like most of the android apps out there, the whole code development was done in one module: app.

Start from scratch

We didn’t have a proper and scalable code structure or framework back then. API and DB queries were being called and parsed in Activity or Fragment, lots of singleton classes holding shared state between pages, it was a mess and was not scalable for new feature additions. The code base was growing and it was very apparent that we needed a better code structure than whatever we had back then.

In August 2015, we decided to rewrite our app from scratch. It was not easy because we had to convince the product team to hold all feature development and told them that this investment would pay off greatly in the future.

The goal, of course, was to have a proper structure. Say goodbye to fragment and most importantly, according to this SOF answer, avoid multi-dex by having a multi-module design. Even with an MBP 2012 with SSD and 16GB of RAM, the build time already took 2–3 minutes.

Hi, modularization!

We came out with our self-designed framework and separated the code by its layers into 4 modules: app, view, model and shared.

Our self designed pattern.

While it might look similar to the common MVP pattern, the implementation was not. One of the biggest misconceptions was we used Activity as the presenter, not the view. But we learned a lot while constructing this framework. We knew what worked and what didn’t. We then used the learning to build the next framework. The one we use today.

Although it was definitely better than what we had previously, after several months we started to encounter problems with the new framework and multi-module design. These were the three main problems:

  1. We hit the 65k method count limit again, so it meant avoiding multi-dex with multi-module design didn’t work, or at least we didn’t do it right.
  2. Resource autocomplete in XML wouldn’t appear for some reason.
  3. When we were adding new features, we touched all of the modules from model to view and consequently we were always building all those modules most of the time — making partial module build didn’t work in multi-module design.

Those problems added with a couple of framework design problems that we found when we actually used the framework for various use cases, forced us to take a deep look at how we could improve.

Migrating to MVP-VM

We started the research in April 2016, just 8 months after the first rewrite of the app initiated and 4 months after the release to PlayStore. The ground up framework was made ready by infra team by August 2016.

MVP-VM Pattern

We used most of the concepts found in MVP, added with ViewModel to the framework: MVP-VM design pattern. Some of the references when we designed the framework were Hannes Dorfman’s Mosby, Nucleus, Nathan’s article from Remind. We also included Android DataBinding because we thought it was a perfect match for our ViewModel.

Goodbye, modularization.

And finally, we gave up multi-module and decided to merge all our code back into one module again. The build time didn’t differ much.

While that was a step backward, but that was crucial for us to know how we should have designed our multi-module app. Little did we know that the single-module design was a dangerous comfort zone if one is not careful. It was also a time-ticking bomb to a project that kept adding more and more features.

Goodbye modularization, it didn’t work out between us. It’s not you, it’s me.

Build Time Problem

In 2017, there was A LOT of products added in Traveloka. We started off the year by launching 3 new products: Train, Attraction & Activities and Mobile Top Up. In June 2017, we launched International Data Plan and Airport Train. Existing products such as Flight, Hotel, etc. also added more and more features in each release.

The code was getting bigger and bigger. With all of that, changing one line in one file took 7–10 minutes to build. We then refreshed our devices to an MBP 2017 15" i7 16GB and sure enough, the build time decreased to only 2–3 minutes. But we can’t rely on upgrading our device every 6 months, we need to find a way where our build time can be as minimum as possible although the code base keeps getting larger and larger.

Hi (again), modularization!

Umm, hi, modularization. Listen, I made a mistake. Let’s try this again, shall we?

We realized that we needed modularization in order to achieve minimum build time. We learned that separating by layer didn’t work in our case, so we changed our approach. Instead of separating by layer, we went with separating by feature. We piloted the project with one of our products with the following objectives:

  1. If anything in Product A changes, other files which are not part of Product A’s dependencies don’t need to be rebuilt.
  2. If anything in files which are not part of Product A’s dependencies changed, Product A doesn’t need to be rebuilt.
  3. app will be rebuilt all the times. So we need to make it as small as possible. We put only SplashActivity in there.

First Attempt…

Dependency Graph. First Attempt

The plan was to isolate all Product A’s files from other products (legacy module). Move all the dependencies that Product A needed to module common.

That seemed to be a concrete plan and we were confident we could achieve it easily. But unfortunately, that was not the case. Moving all Product A’s files to its own module was easy. But moving all of A’s dependencies to common was not something to be lightly taken. The reason is: our code was too coupled between one and another.

Remember that comfort zone that I mentioned before? We were too deep inside it. The comfort zone made us lazy to think about abstraction. We were never coding against interfaces since all classes were easily accessible. We added too many things in our base classes (BasePresenter, BaseViewModel, etc.).

For instance, we needed to move BasePresenter to the common module since it was a base class. And there was a re-authentication logic in it which eventually would need to show a re-authentication dialog, owned by the User Platform team (The team that handles user’s data, authentication, OTP, preferences, etc). It means that we also needed to move the dialog, its ViewModel, Presenter and all dependencies that dialog have. That doesn’t make sense. From Product A’s perspective, it only wants to show the re-authentication dialog, it doesn’t care how you would display or construct it.

Interfaces to the rescue!

The solution for this is to create an interface to handle re-authentication:

public interface ReAuthenticationDelegate {
void showAuthenticationDialog(Params...);
}

The interface along with each function’s return type and parameters will be placed in the common module. While the implementation will be in legacy module. Perfect!

Now, whenever we are creating classes that might be used by other teams/products, we always create an interface for that. Multi-module app development forces us to code feature in a loosely coupled way.

So we figured out how to decouple our code, we started to create a lot of interfaces, in lots of places. This process took weeks or even months. After some time, we decided to try to merge it with develop branch and then…

There was one thing that we didn’t anticipate would pose a problem when we started out the pilot project: other products development was not stopped during the modularization process.

They were still in active development, merging new features, doing changes to their code, etc. And we also changed lots of things in their files—creating delegates, changing implementation, basically doing refactors here and there. There were conflicts everywhere. Solving them is one thing, but making sure everything still worked as intended is another thing.

Second Attempt

We then decided to scrap our first try and made some modifications to our dependency graph:

Dependency Graph. Second Attempt

With this approach, we can still achieve #1 and #3 but not #2 from the objective list. But that is not a big problem anyway because from Product A’s engineers perspective they will mostly edit on their own files so that legacy module will not be built very often.

We also treat the abstractions process as a continuous work instead. We still delegate re-authentication and others. But as a start, both the interface and implementation are placed in the legacy module. After the User platform team created their own user module, they could move the implementation to that module and leave the interface in the legacy module.

The pilot was successful, changing file in the product-a module only took 2–3 minutes to build. The adoption of multi-module design was very fast once engineers realized how much time they could save every time they rebuilt the code. The number of modules in our app grows each month. When we first re-introduced multi-module in June 2017, we started with only 3 modules app, legacy, and product-a. Four months later, there were already 15 more product modules added to the project. Also in Android Studio 3.0, Google had made some improvements in build time specifically to projects with a large number of modules. So we were confident that we were moving in the right direction with modularization.

With our current device (MBP 2017 15" i7 16GB 500GB SSD) it now takes 7–8 minutes to do a full build, and 2–3 minutes for changes in product module. And we are still looking ways to reduce these numbers even further. One module per page? Maybe.

DataBinding Problem

We thought that everything was going well when we saw the enthusiasm of code modularization being adopted by the engineers. But DataBinding held us back.

The problem with DataBinding was the Binding classes generation. In one module, let’s say Product A, it created Binding classes for all XML inside that module and all XML inside module(s) which it depended to.

At that time, legacy still had ~500 XML files from products which were not modularized yet. And we had 15 modules which depended on legacy. That means 15*500 Binding classes would be generated. This occupied lots of memory when compiling. There was a time when adding new modules was not possible because if we added one more module we wouldn’t be able to build the project due to not enough memory allocation.

To make things worse, DataBinding was also leaking memory in java so we had to kill java process every time we wanted to build the project, otherwise it would took very long time since there was no memory left available.

Luckily, in Android Studio 3.1, Google introduced a new compiler for DataBinding—optimized for project with multiple modules. It fixed the Binding class duplication problem and we finally encouraged modularization again to the team.

Conclusion

We’ve learned that building app with well known design patterns and best practices is an investment we should make from day one. It helps us write better, scalable and maintainable code. Here are my top two items on what you should implement from the get go:

  1. Use your preferred MV* pattern. MVP is arguably the most popular one right now. MVI (Model-View-Intent) is also another pattern gaining traction which is kind of similar with our MVP-VM pattern. 
    Google had been quiet if it comes to recommended architecture when building android app. But now they’re starting to make a guidance with the introduction of DataBinding, Component Architecture and JetPack. I personally think Google is leaning towards MVVM with all of these libraries.
  2. IoC (Inversion of Control). This helps you decouple your code easily. Increases visibility of your code dependency graph. Makes classes easier to be unit tested. 
    However, using DI (Dependency Injection) framework such as dagger is optional. Make sure you justify the benefits from the complexity and code size added to your app.

It’s also worth to remember some popular principles like KISS, YAGNI and DRY when building your app. For whatever being added to your app, make sure the costs justify the benefits.

What about multi-module design? It is something that not every app should use from the very beginning. You will need to manage the complexity of dependencies management, extra effort in setting up the groundwork, etc. For simple apps, it might be better to stick with single-module style to avoid all those works and invest on something else more important. But when your app reached a point where modularization is needed, you’ll achieve it easily since you have your code well-structured already with the design patterns and best practices listed above.

Bonus: no-op modules

When we make changes to product module, the time it takes to complete the build is still acceptable. But making changes in legacy will take 7–8 minutes to complete. From product A’s perspective, they don’t care or never access Product B, C, etc. So why have them recompiled each time we changed the legacy module?

We then started to create a no-op module for each product module. The idea is simple. During development, instead of using the real module of other products, swap them out with their no-op implementation which contains only public classes which are being called from outside. You could think of it as mock implementation of a module. The time needed to build time a no-op module will be much faster than real product module since it contains only a few classes and no resources. Each engineer was given the flexibility of choosing which modules they want to build with real implementation and which ones they want to use the no-op implementation.


Hey you made it to the end. If you are excited about this kind of topic you could be the one we look forward to work with! Join us! visit our careers page or drop me a message.