There are many architecture approaches used in the Android development field nowadays. You have probably already chosen your preferred methods and strive to use a unified approach when writing your applications. However, in some cases many approaches have to co-exist in the same project, often in conflict with each other. What’s more, a project can be written with technologies and approaches that become obsolete before the project is even completed. And this is where refactoring comes into play.
The meaning of refactoring can vary a lot. So much so that the only common attribute of refactoring is changing the variable name. But sometimes it’s about changing the architecture of an entire module.
The reason such tasks are performed is because most developers prefer to use new technologies, for obvious reasons:
In simple words, refactoring means changing the implementation without changing the behavior. Just as you start making things cleaner though, you are bound to begin noticing other places that need to be set in order too. And after a while, this evolves into something that can be expressed as a kind of Boy Scouts code - every place you’ve visited should be left cleaner than it was before you arrived.
Some key principles of refactoring are described in Martin Fowler’s book “Refactoring”; they are:
Sometimes it’s very difficult to understand where refactoring ends and rewriting begins. So although our story is about rewriting, we were guided by the rules of refactoring established by Martin Fowler.
The more I
learn refactor, the more I realize how much I don't know still have to refactor.
It’s a classic feeling. You cannot estimate and preview the whole scope before you start a deal. Each refactored line, method or class ultimately leads you to thoughts of rewriting the whole project.
But can you imagine how much time and effort needs to be spent? There are hundreds of thousands of lines of code. Lines that someone is still writing. Don’t forget that the project keeps evolving so making unagreed changes makes it extremely vulnerable.
And this is the very moment at which the third rule from the previous section comes to the rescue: “Refactoring should consist of the small pieces that you can commit”. It may sound odd to do rewriting in pieces but this is the approach that has helped us to perform efficient rewriting of a running project. Step by step, screen by screen, feature by feature - that is how we eat an elephant.
Name: Android application
Age: 5 y/o
Architecture: Single activity app
Design patterns: MVC + MVP + MVVM + their wild mixes
Structure: app module + several modules
Programming language: Java + Kotlin
In a nutshell, the project involves a mobile client for a large US-based department store network. Since its very beginning the application has survived several iterations of rewriting - enough to say that in the first version it was a web view wrapper for the website. The main purpose of the project has evolved from being just a showcase, to a handy place for online purchases, to the application having dozens of unique screens, several payment systems and microservices on the back-end. The building process of the application could take an hour or more just to describe.
In the current on-demand marketplace, customers dictate the rules of engagement. And with the rapid rate of cool new features becoming available, businesses need to be ever-present and just as rapid in development and delivery. In such circumstances, remaining stagnant means falling back. In the case of the project we were tasked with, the application had become barely scalable and the process of onboarding a new team member to work on it would take way too much time.
After contemplating forward-looking ways for development, we decided to perform not just massive refactoring but a total rewrite. Every single entity inside the application would need to be revised and either optimized or recombined with the others.
A brief outline of the refactoring could be expressed as:
The main problem with the application was its age. It is no secret that in mobile development things are changing quickly. For half a decade, the project had been developed and refactored. Some features were written from scratch in separate modules or packages. Some tiny portions of new functional requirements were added in old classes which made the code messy.
In some cases, old code was not removed after refactored code was extracted to new modules or packages. It was confusing for newcomers, because multiple implementations existed for the same functionality. At the same time, adding new code to old code can cause even bigger problems. The development was started when many contemporary key tools like binding did not exist yet. So, the initial implementation was based on the old-school MVC approach.
For sure, when making changes, the initial pattern used for the feature implementation should be respected. For some reason, however, many developers ignore this rule. An alternative to the initial approach is to refactor the complete screen with a new one, but such a change would need to be approved by the business and be well tested. As a result, there are some places in the code where MVC is mixed with MVVM.
The bulk of legacy code was placed in a huge monolith module that was separated by packages. Over the project lifetime, separate modules were created for features that were rewritten from scratch. Some custom views and other common components were also extracted to separate modules. For such a heavy and complex mechanism, Gradle incremental building was not applicable, but this wasn’t the only problem.
Upon investigation of the accumulated tech stack, we discovered a myriad of different tools: Java, Kotlin (it was accepted as a standard for newly created files), Dagger2, RxJava, Retrofit, LoganSquare, Mochi, JUnit, Mockito, and more. In short, we can surmise that the team was experienced with old approaches and tools as well as brand new trends in the Android environment.
Changing old-fashioned approaches is a dream for the majority of developers who work on projects for a solid amount of time. However, only a minority ever actually get the opportunity to do so. Despite the fact that it is not likely for product owners to allow it, sometimes such opportunities arise for a lucky few development teams. This time, we were the lucky ones.
The key challenge we needed to resolve was logically separating and low coupling the updated components. At the same time we needed to ensure that we kept a similar look to the legacy components. With such an approach we can avoid feature rollbacks, and hence, user confusion.
The rewrite project was created as a separate project that could be built and run independently and the rewritten codebase was regularly added as a subfolder to the legacy project using merge procedures. As a result, both projects can be run separately as well as together using special glue mechanisms. Enabling or disabling the rewrite part or specific features is managed by kill switches, also known as feature flags, received from the server.
Further, the rewrite project needed to adopt modern best practices so it strictly followed the accepted vision of Clean architecture and used MVVM as an architecture pattern. Novelties of Android Jetpack could also be used in full power. From an implementation perspective, we kept the convention “only Kotlin for new files”, and technologies such as Dagger2 for DI, RxJava, Retrofit, Mochi. Some additional restrictions were also added to CI, such as code formatting check, test coverage, module dependency rules etc.
In general, the architecture of the rewrite project can be expressed as MVVM + Databinding + RxJava2 + Dagger2 + Kotlin. It was created according to Google’s official recommendations for Android applications and based on usage of Android Architecture Components libraries such as NavGraph, ViewModels, LifeCycle, Room, LiveData, Databinding.
Components of the application were split into several module groups. Every group has its own prefix name in order to make the decomposition process easier.
E.g., Network, Logger, Storage
E.g., adp-da-network, adb-da-logger
E.g., adp-ds-auth, adp-ds-network
ADP consists of domain agnostic and domain-specific components which can be reused to stitch together the application.
Each feature should be split into several gradle modules to promote a clearer separation of concerns and to determine what kind of code is grouped together.
Thanks to our experience working on large-scale projects, we realized that using the feature branches approach for very outdated code was a problem. After rethinking, we came to the conclusion that the best solution is trunk-based development combined with the ability to enable and disable part of the functionality in real time with Kill Switches (KS).
As mentioned, the main purpose of Kill Switches is to dynamically enable or disable certain application features on the same application version. This approach was implemented even for the legacy code. When we started rewriting, we decided to use KS in the new code as well so as to provide users with rewritten screens step by step, and have the ability to disable the feature at any time.
Despite the fact that each feature has KS, it is not a single condition for enabling features for users. Each feature also has a version that represents a minimal application version where this feature becomes available for users.
Having this ‘minimumVersionSupported’ property is a solution for the following possible issue:
Note: Besides Kill Switches for each feature, a global “isAppRewrite” Kill Switch was implemented. Setting this KS to FALSE disables all rewritten code.
Firebase Remote Config is a cloud service that lets you change the behavior and appearance of your app without requiring users to download an app update. Using Remote Config you can create in-app default values that control the behavior and appearance of your app. Then you can use the Firebase console or the Remote Config REST API to override in-app default values either for all app users or for a specific selection of them. Your app controls when updates are applied, and it is able to frequently check for updates and apply them with a negligible impact on performance.
Firebase Remote Config can be used as a Kill Switch to enable or disable some features depending on business needs and state. For example, if some feature is unstable, like Translation, it can be turned off remotely without requiring users to update the app.
Do you need to follow every step we described above? Not really. You know your project best, and it is up to you to decide on the best approach. The reason we decided to share our experience with you is because it is a success story. We did it! However, if you recognize similarities in your project, feel free to use our solutions as a guide.
What do we have after implementing all of the above? Our project is still evolving as we continue to add new features and implement marketing changes, but our client is enjoying the following benefits:
In this article, we omitted any concrete examples of implementation and focused on the approach we took instead. If you found this information valuable, stay tuned and we will share more details with you soon!
Need more information about Android Application Modernization? Get in touch with us to start a discussion.