I have joined asanarebel.com as Senior Android developer at the beginning of February. Asana Rebel is a health and fitness app with workouts for different body and health goals, but all of them are yoga inspired.
When I joined, the plan was to increase the team size and raise the code base overall quality, focusing on the release process, testability and modular architecture, keeping a fast pace and short feedback cycles.
Today I want to talk about modularization and how I managed to split the code base to improve separation of concerns and build speed.
I believe modularization is a key point in any modern Android app. I don’t necessarily mean having many Gradle modules, but having the source code nicely organized in cohesive and decoupled entities is definitely a good idea.
My strategy was mainly to create Gradle modules to increase the build speed, leveraging Gradle parallel build and caching strategies.
Attempt #1 — The bold way
I have a rough idea of what the app is doing. I’m going to split it by use case!
After a quick project overview, it seemed a reasonable idea at the time. I mean, we have the onboarding, we have the login/sign up, we have the Today screen, we have the programs, Settings, Profile screens: it looked feasible.
I started moving pieces around, keeping an eye on the build results, leveraging IntelliJ massive refactoring capabilities, and I failed miserably. No surprise though, this already happened in the past, I had the exact same experience in previous companies: no matter how much do you think what you are doing makes sense
IT.WON’T.BUILD.
Single module projects evolve in a very specific way: messy. It sounds harsh, but unfortunately it’s true. When you can access everything from everywhere, you stop thinking about how things are connected, the only thing that you see is the next dependency that you need to satisfy to complete the current task. If you are keen to zero testing #yolo, static methods, getInstance()
s or poor Dagger design, well, it’s just a matter of time before everything turns into a spaghetti code hell, where everything is referencing everything else accessing things in a blocking fashion or, even worse, playing Russian roulette with race conditions.
After two days of digging my way down to the bottom of the rabbit hole, I gave up. I reverted everything and I went back to my app-android/app/src/main sadness.
Attempt #2 — The :base module way
A few weeks ago I came across an inspiring talk by Marvin Ramin (https://twitter.com/Mauin), http://uk.droidcon.com/skillscasts/10525-modularizing-android-applications, where he shows an interesting approach he’s been experimenting with for a while now: pull the monolithic module :app
down in the dependency graph, renaming it to something like :base
and from there walk your way up and sideways, pushing classes to new separate modules that may depend on :base
instead of :app
.
This is an oversimplification, of course, and I advice you to give the talk a try. I’m pretty sure you will relate with the pain that Marvin brings with himself after God knows how many trips down to the rabbit hole. I definitely could relate.
Unfortunately I couldn’t get any progress with his approach either. For some reason the :app
is too entangled and I’m also in the middle of a conversion to Kotlin and Kotlin DSL, removing 12 flavors and 4 build types doesn’t help, documenting as much as I can to prepare the project for the new coming developers.
I know what you are thinking:
Come on, man! How about fewer things at the same time?! 😂
I know I know. What can I say? #yolo
Attempt #3 — Patience
I believe Marvin approach is solid and can work as a starting point, so even if I couldn’t move the :app
module straight away, I started to browse the code base looking for another attack point. I’m new to the project so it was also a nice exercise to better understand the architecture, overcoming the fear of touching too many things and breaking everything.
I started looking for leaf classes: data classes, utility classes, interfaces with meaningful names. I had one simple goal: moving as many files to new modules as possible.
It sounds a bit pointless, but my goal was to break things to figure out how things were connected and coupled together.
- So you open a file
- you check the imports
- if they feel like reasonable you move the file to a new module that makes sense with the file
- run the build, run the tests, run the app, looking for crashes or regressions.
This is super tedious, and can be very frustrating, but if you are lucky, things don’t break too much and after a while you end up with a git history looking like this:
In real life, we are not lucky. Com’on! If we were lucky we wouldn’t have been here in the first place, right?
Things that broke during the journey
The first things that blew up were the Dagger scopes.
There were:
-
@ApplicationScope
, a custom application wide scope. Not sure yet why this was preferred toSingleton
-
@UserScope
, a scope designed to exist only when a user was logged in -
@ActivityScope
and friends
Scopes were OK from a design point of view, but in the practical implementation they were very messy and fragile due to a lack of proper dependency injection.
The UserScope
set, for instance, initially looked like a nice candidate for a :user
module. Working with the usual approach
- I created an Android Library module called
:user
and added it to:app
build.gradle
. - Once I had the module, I started moving the tiniest and decoupled files I could find that could belong together in a
:user
module.
If you are like me, in a similar scenario, you would get in the zone, you would iterate over and over: move, build, test, commit, push. After a couple of hours, you sort of hate yourself, your CI hates you for the 150 builds your ran since you started, you start questioning your career, you feel like you will never see the end of this, imposter syndrome ramps up.
No worries, you are just low on sugar. Take a break, drink some water, eat a banana, troll an iOS colleague about how hard it must to be for them to support 4 devices and 2 OS versions at the same time and than go back to your task.
So far so good, then I reached a point where things started breaking for real: I found a couple of classes in the UserScope
domain that required ApplicationScope
classes. If you scope your app properly this shouldn’t be possible, but if you are working for a startup, accepting hacks and technical debt is part of the game and things get tricky.
To solve this issue one would have needed to rework the whole scoping, but I believe there was no time, so the solution was the infamous quick and dirty hack, a.k.a. quick to be forgotten, dirty forever:
I need the
[ADD RANDOM]Manager
, so I’m gonna access it statically.getInstance()
FTW #yolo
This is, in my opinion, the starting point of the decline of the whole code base maintainability. After this moment, every time you hit a blocker, you will feel the pressure, you will know that you should try to make things right, but you will use that one time you did the hack as an excuse for every single future hack, because hacks take the discomfort away quicker than the proper solution.
For me, eventually this will need a major rework, but at that point it will be a technical/business decision: investing today to be consistently fast over time.
The second major thing that broke without mercy was Proguard.
No surprise here. I believe Proguard with its hard-coded package paths and carved in stone file names is not ready for dealing with this kind of infrastructure rework.
I’m sure that pro-Proguard people will yell at me that if you configure it properly everything will be fine, but I work in the real world where projects evolve quickly, people change, things get complicated exponentially because of… reasons and Proguard has never been a factor of simplification, on the contrary it always added more uncertainty and frustration.
In AsanaRebel, for instance, everything looked OK, no warning, no error, builds were successful, but at runtime the app just kept blowing up due to some missing resource file wrongly taken away by Proguard. Tracking down these things is exhausting and builds up a lot of frustration, for a still unclear final gain of some KB in your final APK.
Current status
At the moment we have a dozen of modules and counting. Some of them are pretty fat, others just contain a few files.
The :core
module, for instance, is a sort of safe-harbor module, where dependencies that should be moved from :app
to a module :foo
can’t be moved there because they are also needed by a module :bar
and this would create a weird circular dependency, so :core
becomes this thirth party that helps to decouple things in the meantime that a better solution is put in place.
The going forward approach is creating a new module for every new feature. With this approach we are certain to leverage Gradle parallel build, we can also enforce a better separation of concerns and testability, and most of all, in the very fast and very experimental mindset that we have in AsanaRebel, if we want to drop a feature because it’s not performing well, we simply delete a folder.
Eventually we can guarantee that only the old and very coupled code is still in the :app
module, contained and waiting for improvements.
On this note, the :app
module will also contain a package with the same name of every Gradle module we have. These packages will contain Activities, Fragments, Adapters, Views related to that module and will allow the dependency injection. Only files in :app
can access the main Application
file and then the Dagger component and perform the injection. So far I was not able to find a solution to perform the injection from an Activity
living in a module.
The more familiar I get with the code base, the easier will be for me to see how things are connected, how to rework the dependencies and how to move things to the proper modules. This is an incremental process, it’s slow, it takes patience and it can be very frustrating, but I believe it’s the right thing to do if you really care about the product you are working on.
With more developers joining the AsanaRebel Android team in the future, refactoring the old code base will be a team effort, somehow a team building exercise, instead of a single developer going John Wick mode, head-shooting every getInstance()
until the end of time.