Rewriting Uber Engineering’s Android Rider App with Deep Scope Hierarchies
March 22, 2017 / GlobalWhen we rewrote the Uber iOS and Android rider apps in 2016, we subdivided the app into a deep hierarchy of dependency injection scopes. This allows more features to be written without knowledge of one another and reduces the amount of stale state in the application, thereby increasing engineering velocity and facilitating growth.
While iOS frameworks have ViewControllers that always supported composite patterns, AOSP (Android Open Source Project) frameworks have not traditionally supported deeply nested controllers or scopes well. As a result, writing Android applications with deep scope hierarchies is difficult and uncommon. Uber’s Rider app is an example of how this difficulty can be worth overcoming to solve structural challenges.
The Uber rider app’s UX contains states that share common objects such as the map; for example, the home screen view, the product selection view, and the airport terminal view, shown in Figure 1:
The existence of shared objects between different screens means the application cannot be composed of a distinct set of Activities (e.g. HomeActivity, AirportActivity, and ProductSelectionActivity) unless shared objects are stored as singletons in global scope. To address this, a pattern is needed to control how objects are shared between screens and subscreens.
In short, we needed an effective scoping pattern to support the new Android Uber rider app.
So, how did we do this? In this article, we discuss:
- The shallow scope pattern we used in the old rider app and its problems.
- The deep scope pattern we used in the rewritten rider app and its improvements.
- Different architectural frameworks and how they support deep scope hierarchies.
The Old Uber Rider App: A Two Level Scope Hierarchy
By 2016, it became apparent that we had outgrown the existing Uber rider app, as it could no longer keep up with the scale and speed we needed to maintain and grow operations.
Most of the old rider app is contained inside a single activity, called LoggedInActivity, that behaves like a controller with a single scope. There are multiple distinct screens within LoggedInActivity that perform drastically different behaviors. Therefore, LoggedInActivity is composed of one additional layer of sub-controller classes that handles different UI and business logic. Some of these sub-controllers are shown below in Figure 3:
All of the sub-controllers such as CommuteController, PricingController, and AirportController live in the same LoggedInScope and all dependency injection objects can be shared between all sub-controllers. See the dagger snippet below:
@LoggedInScope
@Component(modules = LoggedInActivityModule.class, dependencies = AppComponent.class)
public interface LoggedInComponent {
void inject(LoggedInActivity activity);
}
Consider controllers such as the AirportController (refer to the middle screen in Figure 1): this AirportController exists in memory for the entire duration of LoggedInActivity. This condition has several downsides:
-
- Coupling: Other controllers, such as CommuteController, can read and write from AirportController’s utility objects since they share a scope. This inevitably leads to coupling between unrelated controllers and features.
- Stale state: All utility objects used by AirportController exist in memory after AirportController has finished displaying itself on the screen. This forces engineers to write error-prone reset logic for AirportController’s utility objects when AirportController is hidden and then later shown again on the screen.
- State combinatorics: Since objects and controllers remain in memory for the duration of the entire LoggedInScope, classes need to know how they should behave during every LoggedIn substate. This requires engineers to write larger classes with more error-prone state condition logic.
- Hard to update and test: When adding a new substate, engineers need to consider how dozens of controllers, such as AirportController, and utility classes should behave in this new state. The Uber rider app has a shockingly large number of features as a consequence of operating in hundreds of different cities with varying constraints and optimizations, so a two level scope hierarchy quickly becomes unmanageable.
Are Three Level Scope Hierarchies Better?
With two layers of scopes, the old rider app is a drastic example of what happens when your scope hierarchy is too shallow. Unfortunately, creating an additional layer of scopes by giving each controller its own scope fails to solve most of the problems outlined above.
Objects often need to be shared between two or three controllers. With only three layers of scopes, these shared objects need to be stored in the LoggedInScope. Over time, the third scope layer becomes “thin” as many objects are refactored into the second layer of scopes.
Clearly, adding a third layer of scopes is an improvement. But this still causes a “fat” second scope layer with lots of the same problems.
New Rider App: Deep Scope Hierarchy
Given that two and three level scope hierarchies have major problems, we did not limit ourselves to a set number of scope layers when we were developing the new app. Instead, we created new intermediary scope layers wherever useful. For example, the PreRequest scope is used to store objects that need to be shared by all PreRequest screen states such as Home, ProductSelection, and RefinementSteps.
This pattern results in a deep hierarchy of scopes (see Figure 4), providing two high-level benefits:
- No data or views are shared between siblings in the scope tree. Objects that need to be shared are stored in intermediate nodes, so leaf scopes are well encapsulated.
- Since no internal data is shared between scopes like Airport Door Selection, Location Refinement, and Product Selection, none of the Airport data needs to be in memory after the Airport Door Selection flow is complete. As a result, the new app’s controller hierarchy can map 1:1 to its scope hierarchy.
The problems in the old rider app caused by two level scope hierarchy disappear when we subdivide the application into a set of small scopes with short lifespans, per Figure 4. Consider how this new scope and hierarchy affects the Airport feature, below:
- Less coupling: The Airport logic cannot access any memory from sibling or cousin scopes. As a result, development of features can be done independently inside Home, Product Selection, Airport, and Location Refinement.
- Less stale state: The Airport Selection scope is only in memory when its logic is executing. Therefore, there is no need to write error-prone reset logic when hiding the airport UI.
- Less state combinatorics: Most objects no longer live for the entire duration of the LoggedIn scope. For example, the Product Selection logic doesn’t need to make any decisions regarding its behavior when inside the Airport Selection state because none of the Product Selection logic exists in memory during airport door selection.
- Easier to update and test: When adding a new substate to the application, engineers do not need to test its impact on as many existing features because of the lack of state combinatorics.
Common Architectural Frameworks
There are many different ways to create deep scope hierarchies, so we had to assess all our options before settling on one. We discuss the architectures considered before we decided to rewrite the rider app using RIBs (otherwise known as Riblets), our internal architectural framework, below:
MVC and VIPER
The codebase engineering inherited from the old rider app followed the MVC (Model-View-Controller) pattern. Common textbook patterns like MVC or VIPER are general enough that they can support deeply nested scope hierarchies, but the controller hierarchies are typically dictated by view nesting. This is inconvenient for deep scope hierarchies since many scopes do not create any views.
So we didn’t go with MVC or VIPER.
Flow Apps
Flow was primarily designed for the purpose of supporting multiple levels of nested scopes. Since you can create viewless scopes that contain nothing except shared objects (e.g., a LoggedInScope), Flow was a strong option. But other factors (for example, its lack of a corresponding iOS framework) prevented us from using it.
So we didn’t “go with the Flow.”
Conductor Apps
Frameworks like Conductor don’t explicitly support scoping or nested scoping. You can add scopes to every controller if you’re willing to overcome:
- No enforcement of DI patterns: This is important if you’re going to use lots of layers of scoping.
- Redundant views: Conductor forces every controller to create a view, leading to redundant views when using deeply nested scope hierarchies.
Given these constraints, we resisted choosing Conductor.
Scoop Apps
Other applications also contain shared view objects and business data between their screens. For example, Scoop was based on an early version of Flow to formalize a controller pattern that can share views like maps without creating global state.
The Scoop framework strongly emphasizes scopes. With Scoop, scopes are correlated with the navigation stack: going deeper in the navigation stack nests a scope below the current scope. For example, when transitioning from HomeController to ConfirmationController, objects can be shared between them by giving ConfirmationController access to HomeController’s scope.
Scoop’s design provides convenient navigation and animation patterns at the cost of encouraging greater coupling from controller to controller and from controller to activity, patterns we were determined to avoid.
So we didn’t Scoop up this option.
What’s for Dinner: RIBs
Since none of the pre-existing options we considered met Uber’s requirements, we created our own architectural framework for our new rider app: RIBs, which we detailed shortly after its debut. Unlike Scoop, the navigation stack and scopes are decoupled, and the only shared objects between Home and Confirmation exist inside an intermediate PreRequest scope. With RIBs, using a nested scope pattern for our rider app is easy because of two design decisions:
-
-
- The scope boilerplate is generated for RIBs: We created an internal IntelliJ plugin that generates the RIB and Dagger 2 component/subcomponent boilerplate whenever an engineer creates a new RIB. As a result, nested scoping is a low-friction norm.
- Scopes don’t need to be coupled to views: With a deeply nested scope/controller hierarchy, this functionality is useful. Many leaf scopes will want to mutate views from intermediate scopes instead of creating their own views, and many intermediate scopes will contain only business logic instead of views.
-
Deep Scopes FTW!
Deep scope hierarchies enable applications like our rider app, with its feature-dense screens and shared objects between subscreens, to increase separation of concerns, reduce possibilities for stale data, and increase developer velocity.
Once an app contains a deep scope hierarchy with highly decoupled controllers, it becomes easier to add powerful technologies such as static analysis to detect memory leaks, plugin-based programming, and various performance optimizations.
Brian Attwell
Brian Attwell worked as a staff software engineer at Uber.
Posted by Brian Attwell
Related articles
Most popular
How to Measure Design System at Scale
Introducing the Uber Eats Pro pilot with Preferred Deliveries, Planner, and updated criteria
Preon: Presto Query Analysis for Intelligent and Efficient Analytics
The Accounter: Scaling Operational Throughput on Uber’s Stateful Platform
Products
Company