In 2019, Yelp’s Core Android team led an effort to boost navigation performance in Yelp’s Consumer app. We switched from building screens with multiple separate activities to using fragments inside a single activity. In this blog post, we’ll cover our solution, how we approached the migration and share learnings from along the way as well as performance wins.

Where we started circa 2018

Navigating between screens in an Android app is often when the app and device are under the most strain. The new screen and its dependencies are quickly created, which can lead to slow or frozen frames. Prior to 2019, almost every page in Yelp’s Consumer application was in its own activity. Transitioning from one page to another was not always smooth, and the UI was often visibly slow to the naked eye while navigating around.

Clicking on one of the bottom tabs meant recreating everything from scratch each time a user navigated to a screen. We mitigated this in the short term by bringing an activity to the foreground instead of recreating it if it was present in the activity stack. Although this helped, it still didn’t result in the visibly silky-smooth navigation transition we were hoping for.

To understand where we could have the most impact and to help focus our efforts, we first did some local benchmarks followed by monitoring performance data from real users to verify our hypothesis that the navigation performance was slow.

Local Benchmarks

In 2018, we performed some basic navigation tests on a Pixel device running Android 7.1.1 and measured how long it took from the moment a button was clicked on Screen 1 until Screen 2’s onCreate() lifecycle method completed. The following numbers are an average value derived 10 iterations per scenario:

Scenario 1st Time Navigation to Page (ms) 2nd Time Navigation to Page (ms)
Plain Activity with no animation 152 65
Plain YelpActivity with no animation 420 116

We had to ask ourselves, why is the Yelp base activity so much slower? It turned out it was a combination of many things.

  1. We were creating the Navigation Drawer as soon as the activity was created, instead of doing it lazily—only when the user opens it. This was true even for the bottom tab activities.

  2. The layout hierarchy was deep, containing unused and unnecessary layers that could be removed.

  3. Each page had to create the entire layout hierarchy, instead of just the content above the bottom navigation bar.

  4. We had some slower calls during onCreate(), such as analytics calls and accidental disk io that should be run on a background thread instead.

  5. We had lots of small, cheap objects that were setup on each screen to ease development, the sum of which amounted to a significant portion of the slowdown.

Production Data

We also collected some navigation performance data from real users over a five-month period. Below is the data for two high-traffic flows:

Flow Average (ms) P99 (ms)
Home to Search Overlay ~200 ~1000
Search List to the Business Page ~240 ~380

We learned the performance was lacking, verified our local benchmark numbers, and proved our seen-with-the-naked-eye hypothesis about the navigation transitions.

Based on this research, Yelp’s Core Android team decided it would be invaluable to tackle this problem. The recommended architecture for a bottom tab screen is to use a “single” activity with multiple fragments or views. The theory behind this is that creating a fragment or view is much faster and cheaper than creating an activity. However, there are many ways to implement this, so we had a big decision to make.

Fragments vs Views

To determine what screens should be made of in our new single activity setup, we did some more local benchmarking. These measurements were also taken from a Pixel device running Android 7.1.1.

Scenario 1st Time Navigation to Page (ms) 2nd Time Navigation to Page (ms)
Plain View with no animation 6 3
Plain View with animation 6 3
Plain Fragment with no animation 14 12
Plain Fragment with animation 15 11

We found that either of these solutions resulted in significantly faster navigation performance than using activities. We also benchmarked with and without a shared element transition between screens to evaluate their impact on performance and found they had a negligible negative impact.

Based on the above, views were clearly the fastest in terms of the timings, but these numbers didn’t tell the whole story. Firstly, the difference in the timings is not visible to the naked eye and both represent a significant performance gain over status quo. Secondly, besides considering performance, we had to also consider the development experience for our Android community and the ongoing support available to us in the future from whatever solution we selected to build our new single activity.

Approach 1: View-based navigation with Conductor

Google didn’t directly support View-based navigation at the time (it has since become possible with Compose-based navigation). In order to use views, we would have needed to either find an existing view-based navigation architecture library or build one ourselves. There were some promising open-source solutions, such as Scoop by Lyft, Flow & Mortar by Square, and the Conductor library by BlueLine Lab’s. However, 3rd party open-source libraries come with their own set of risks and challenges, such as being dropped or deprecated in time, as happened in two of the libs mentioned above.

We evaluated Conductor and it had many advantages such as:

  1. Lightning fast transitions
  2. Great API
  3. Support for shared element transitions out-of-box
  4. Support for RxLifecycle and other architecture components via add-on libraries

However, ultimately, we deemed the risk of using a 3rd party library for navigation to be too great. While views were technically faster, after taking everything into account, we decided to use fragments.

To use fragments, there are a variety of old and new options provided by Google. Unlike views, fragments are intended to be used as screens within a single activity flow. So by choosing fragments as our solution, we benefit from all the support that comes with it, such as documentation, testing, and lifecycle management.

Approach 2: Google’s Jetpack Navigation Library

The first fragment-based solution we evaluated was Google’s Jetpack Navigation Library. The library was quite new, but it seemed like it should have suited our needs. Developers define a navigation graph in XML and the library auto-generates code to make navigating between the screens defined in the graph simple. However, we quickly discovered various limitations and obstacles to using this.

Blocker #1: Feature Modules

Yelp’s Android build is modularized with each feature residing in its own Gradle module. To keep our build speed lean, we don’t allow Gradle modules in the same layer of the build hierarchy to depend on each other. This allows modules to build in parallel, which unlocks a slew of build performance wins.

Defining navigation routes in an XML file meant having an app-wide navigation graph in a build-layer higher than the feature layer in the build hierarchy. Fragment IDs also had to be declared down the hierarchy to be accessible in all modules and permit inter-module navigation.

Blocker #2: Scalability

Declaring all screens in a single XML file would also have led to a major scalability issue, where we would have one giant and hard-to-read file which all teams would iterate on frequently. XML is also not dynamic enough for our use-cases. Due to a performance issue in the Android Gradle Plugin, our build times also tripled when attempting to declare the fragment IDs in a lower-level module. Lastly, even with the above approach, inter-module navigation became tricky and negated most of the benefits the library provided.

After five years of improvements, the Jetpack navigation library can handle more use cases. It is now possible to create a navigation graph dynamically in Kotlin, which should help with some of the issues we faced. We reevaluate this regularly and may switch to using it at a later stage. Overall, this is a great navigation library and we currently use it for small flows within a larger screen.

Selected Approach: Plain Old Fragments

We decided to use plain fragments without using the Jetpack navigation library. Fragments are a well-supported part of the Android ecosystem and are familiar to most developers. By using plain fragment navigation, we could get the performance benefit we wanted, get visually pleasing transitions, and solve the cross-Gradle module navigation issue we encountered in the Jetpack Navigation library.

SingleActivityNavigator - A welcome layer of abstraction

Android provides a FragmentTransaction API for showing and hiding fragments, which is what we use under the hood. However, we added a layer of abstraction which hides FragmentTransaction and other navigation specific code from features. We use layers of abstractions when we can to great success. This gives our future selves (thanks, us!) a great advantage by allowing us to switch implementations if necessary, but without updating every navigation point in the app. This abstraction layer exists as an interface we imaginatively named “SingleActivityNavigator”.

Navigating from one screen to another screen in the single activity requires creating an instance of the new fragment and then calling displayInSingleActivity which, at minimum, requires an Android context and fragment tag.

Bottom Navigation Bar

We built the bottom tabs like a regular feature using our MVI library “auto-mvi” which is both performant and easily testable. Now in the single activity, there’s only one instance of the bottom tab bar and it’s shared among many screens. This speeds up fragment creation and fragments in the single activity only need to inflate the content above the bottom tab bar.

We removed the navigation drawer as it was already an outdated Android design trend at the time, and instead moved the content to a “More Tab” accessible via the bottom navigation bar. This boosted performance both for the fragments within the single activity and the single activity itself, as it was no longer required on every screen.

We allow each fragment in our single activity to configure screen-level properties through the SingleActivityNavigator interface. These properties configure each property required when displaying a fragment and reconfigure the screen’s properties for the last fragment’s requirements on navigating backwards. Configurable options include: the status bar color, status bar icon color, whether the fragment content should be under the status bar and window background color.

Cross Gradle-Module Navigation

We use dependency injection to retrieve fragments based on a dependency injection string key. This lets us keep fragments in separate feature modules and retrieve an instance of them from anywhere else in the app. One advantage of using the SingleActivityNavigator interface is that, while we mostly use dependency injection to retrieve the fragments, it’s not a hard requirement. We can retrieve fragments by other means, which for our use-case was important to allow backwards compatibility with some legacy code.

Another advantage of this approach is that it keeps build times fast with our modular Android Gradle build.

In the Yelp Consumer app, each external deeplink first passes through an activity whose sole purpose is to process the deeplink’s URL parameters and decide if the URL is safe and/or correct. These activities are called URLCatcherActivity’s. Each deeplink destination has its own designated URLCatcherActivity. After processing the URL and parsing whatever relevant data there is, this activity is then responsible for navigating to the actual target destination within the app. While these intermediate activities during app launch are not the best for our app’s cold start timings, we do benefit from avoiding a monolithic-style deeplink handling class and improved readability and testing.

This brings us to how we added support for deeplink navigation to fragments. Building on the above section, we know we can use dependency injection to retrieve a fragment based on a string key. The key is used to fetch an instance of a fragment from the dependency graph. To navigate to a fragment based on a deeplink, we use an intent extra which denotes the fragment to display. After parsing the data from the URL, we pass it in an Intent. The single activity then uses this Intent extra to fetch an instance of the fragment from the dependency graph. It passes data from the URL into the fragment’s arguments and then finally displays the fragment.

While this solution satisfies our requirements under the constraints (requiring a URLCatcherActivity), further performance improvements are now unlocked and possible since introducing the single activity. To improve deeplink navigation and cold start performance further, we can now deeplink directly to the single activity and display a fragment which is a significant improvement over status quo.

Migration Path

There were three phases to the migration to fragments. Before we could begin the actual fragment migration, we first had to address the navigation drawer and move it to the more tab. Next, we migrated each activity to a fragment while leaving the original activities in place. These activities were mostly empty shells at this point and used the pre-existing navigation code. Next, we gradually rolled out a version where each fragment was used in one activity. Lastly, we monitored navigation performance to verify if we achieved the expected improvements.

Performance Results

When recording measurements from production, we focused on tracking the highest traffic screens in the Consumer app. We only tracked the first time a transition occurs within each session because this is where the change is most impactful and noticeable. We found that after screens are already created, navigating among them is exceptionally fast. So, remember the following result includes creating the fragments’ views too.

On average, across all Android versions and device models (low & high end), we saw a ~30% navigation performance boost. Sometimes, we saw as high as a ~60% improvement in navigation time. The performance improvement depends really on the screen, what it’s doing and how it’s built internally.

Conclusion

We learned that multiple fragments in a single activity perform much faster than multiple separate activities. We accomplished visibly smooth animations between our screens while leaving our fragments in separate feature modules. Doing a migration like this gradually and safely is totally achievable

Although the performance of the underlying Android components - activity / fragment / view - varied quite a bit, the performance gains in any project always depend on the use-case specific code and solutions already in place. That’s why on the Core Android team, we try to tackle performance holistically with performant-by-default solutions when and where we can.

Our single activity implementation has been working well for many years now, with many teams that work on the Consumer app having adopted the pattern for their screens. Our business owner app also followed suit and migrated to a fragment-based single activity. While our apps are smoother now, we remain optimistic that the Jetpack navigation library will someday solve all of our requirements.

Acknowledgements

A huge thanks to Core Android’s managers at the time before and during this project, David Brick and Antonio Hernández Niñirola, who helped us make a case to do this work and push it forward. A special thanks to all the feature teams and developers who migrated their screens and provided code reviews, such as Diego Waxemberg, Tyler Argo, Lasya Boddapati, and Sreenivasen Ramasubramanian. Finally, a big thank you to my fellow Core Android members for providing invaluable thoughts, feedback and insight along the way.

Become an Engineer at Yelp

We work on a lot of cool projects at Yelp. If you're interested, apply!

View Job

Back to blog