PagerDuty Blog

Android Architecture at PagerDuty

Android has matured as a platform significantly since its release ten years ago. ~Google~ The community has developed best practices and continued to refine them every year, often times, reinvent it quite drastically. Unfortunately, codebases may not necessarily keep up with the newer, evolving standards and have kept accruing technical debt from old best practices (Asynctasks, Loaders, SQLite DB’s, JobScheduler, etc.) as time passes.

PagerDuty engineers created the first iteration of our Android app during a PagerDuty hackday to provide a more seamless way to notify our users. Despite its humble origins, the app has grown to encompass many of the core features of the PagerDuty product today. As PagerDuty continues to invest heavily in mobile, I joined on the recently formed dedicated mobile team.

It is intimidating and often difficult to start working on a new, unfamiliar codebase. Seeing a bunch of class files pop out when first opening the project package is a bit disorienting. Different naming styles, coding standards, libraries, infrastructure, and design patterns (or anti-patterns) makes absorbing a new codebase a daunting task. But why should it be? It’s a question with an obvious answer but it doesn’t really have an easy solution.

Looking a little deeper into our codebase, we had almost 60 dependencies in the main module, over 50 different fragments including an inheritance structure that spanned 5 layers deep, a complicated inheritance structure for the RecyclerView Adapters, generic naming in XML, a main activity with 1000+ lines of code… and the list goes on. It is a legacy codebase, no doubt, but when there are obstacles, there are also opportunities.

However, these issues aren’t unique to PagerDuty. I have ran across some permutation of legacy code for every company that I’ve worked for and I’m sure you have as well. Many mobile apps were born out of good ideas and built by developers of varying levels in a still relatively new mobile field in a very DIY framework. To compound the effect, Google didn’t make it easy for developers to learn how to architect an app until recently with their introduction of Android Architecture Components.

Without any sustained effort to write legible and quality code, current and future developers will struggle to make sense of what is happening when they debug an issue and run into code like this:

init {
    items.add(NavMenuItem(
            R.string.nav_item_open_incidents,
            0,
            Func0 {
                if (account_abilities_provider.isAble(PagerDutyConstants.STAKEHOLDER_DASHBOARD)
                        && currentUser?.role == "read_only_user") {
                    fragmentNavigator.goTo(
                            StakeholderDashFragment.newInstance(),
                            R.string.nav_item_open_incidents,
                            StakeholderDashFragment.TAG)
                } else {
                    fragmentNavigator.goTo(
                            TabbedOpenIncidentsFragment.newInstance(notificationPayload?.incidentId),
                            R.string.nav_item_open_incidents,
                            PagerDutyConstants.TABBED_OPEN_INCIDENTS_FRAGMENT_TAG)
                }

                notificationPayload = null
            }, ""))
            
            //More of the same..
}

It’s obviously some sort of navigation logic but what’s the string resource for? Why is there a 0 and empty string required in the constructor? What exactly is items? These sort of questions add up and creates a barrier for efficient development. Here is how our team tackled these issues to modernize the Android application.

Our goals

To understand what we wanted to do, it is important to know the goals we want to achieve. There are many blog posts and talks advocating for having MVx architecture in your app. One giant activity file filled with complex logic doesn’t provide what we needed. What we valued was the ability to unit test and have an architecture that facilitates it. To make it possible we had to move the business logic from our Android components into pure Java/Kotlin classes to make it JUnit testable. Having more unit tests also allows us to do more iterative development instead of waiting for our suite of Espresso tests to finish running everytime (it’s in the range of 30+ minutes on our CI!). We also wanted to reduce regressions due to tight coupling between the view, business logic, and implementation logic as we added or removed components in the app. Most importantly, we wanted to scale the app as developers joined and features were added.

Before implementing an architecture, we identified different steps that we could work on to expedite the eventual refactor. Let’s call this the first phase.

From legacy to less legacy

Add dependency injection. DI is an important architectural pattern that lends itself well in promoting the testability of code. Any DI framework works, just use one! For our project, we chose to use Dagger 2.

To add Dagger to a legacy codebase can be tricky but it is doable if approached methodically. First, you want to identify your dependencies and then determine what dependencies dependent on. Are there circular dependencies? You might have to extract the circular logic and put them into one? Is it not doable? You can try lazy initialization of your dependency.

Next, check if there is code in your fragments/activities/application that is unrelated to setting the layout which can be moved into a separate class then injected in, especially if the code is being called in multiple places. Singletons are great candidates to be injected and those getInstance() methods that are littered everywhere can be removed with a simple @Inject. If your classes or methods depend on any other class, pass those dependencies in through the constructor or as a parameter. A caveat is to not pass mutable parameters like data primitives as it will likely lead to bugs. This breaks the tight coupling between classes that makes testing difficult.

Lastly, figure out a suitable scope structure. A simple scope structure can be Application/Activity/Fragment scopes which are handled by the latest versions of Dagger 2.

I wanted to improve the readability of the project for new developers and to organize classes in a more friendly way when working on specific screens or features. Modularizing the project involves extracting out features, common code, and libraries to separate Android modules. This will scale well as more and more developers work on it, and you can be certain to avoid any unnecessary coupling.

One of our long-term goals was to get rid of the inheritance structure within parts of our app. “Favor composition over inheritance”. Eventually that structure will be taken out (fingers crossed!), but it was necessary to introduce another base class to inherit from, one that determines the structure of the model, presentation, and view layer, and then abstract out the common logic to adhere to the pattern we chose.

There were certainly more steps that we worked on but it was more specific to the PagerDuty project including tasks like upgrading libraries and frameworks and adding Kotlin to the project. With these steps complete, the next step is to actually refactor the fragment/activities and move the business logic into a different layer.

One thing to remember though is that the landscape is always changing. For example, most of our new layouts use ConstraintLayouts now but there can always be a better layout on the horizon (MotionLayout?). The Kotlin bombshell dropped last year and its introduction still reverberates throughout the Android community. Libraries added to Android Jetpack are guiding developers more towards the engineering vision that Google wants apps to follow. We strive to be modern, but in the end, we probably end up just less legacy than before.

Lessons learned

After finishing these steps, there are a few lessons that I’ve learned:

The move from a legacy codebase to a sleeker, modern, architectured one is a challenging task. It takes a lot of resources, and there can be a lot of missteps and headaches caused by choices made by the previous developers. To continue with the push to modernize, it is important to get the team to buy in to the proposed changes so that they can adjust themselves to work with the new patterns. Accentuate the benefits and attempt to pay down the technical debt that has been accrued for so long. Having the support of the team while working on this made it less of a challenge than what it could’ve been.

For a growing business, there are a lot of priorities and demands. Even if there is agreement to work on reworking the architecture, there might be competing demands from the business side on adding new features which brings more immediate value. There might not be always time in the roadmap for working on architectural changes, but the foundation from what we did in the first phase is still there and primed to go whenever we want to start it again and any new code will follow the new pattern.

The changes we’ve added aren’t just for architecture. They can also help with navigating within the existing codebase. Changes like adding dependency injection have already led to productivity and code quality improvements. Adding modules for our libraries also made it easier to change existing functionality without affecting other parts of the application. Code readability should also be a big consideration when refactoring. Having complex and hard to understand code is a productivity drain for new and old developers.

Overall, the work we did was the easy part. Up ahead lies the challenge of reworking dozens of fragments. The most difficulty we’ve encountered was adding Dagger and changing our Espresso test suite as a result of it. Refactoring isn’t easy especially with very old codebases but it is a step-by-step process. We’ll let you know how it goes with the next phase!