This article is the tenth and final in a series covering how Uber’s mobile engineering team developed the newest version of our driver app, codenamed Carbon, a core component of our ridesharing business. Among other new features, the app lets our population of over three million driver-partners find fares, get directions, and track their earnings. We began designing the new app in conjunction with feedback from our driver-partners in 2017, and began rolling it out for production in September 2018.
Many mobile apps are used for specific, user interaction-based tasks, such as sharing a photo, sending a message, or browsing information. Uber’s driver-partners, on the other hand, keep the Uber driver app running for hours at a time on their phones, finding fares, getting route guidance, and checking their earnings. Most of these features require users to open the app and keep it in the background on their phones.
We designed our original driver app to use Services in accordance with best practices for general use Android apps. Over time, however, we found that this approach was not the best fit for an app that needed to be kept running, resulting in overly complex code with duplicative features and unexpected behaviors.
As we began the process of rewriting our driver app, we were afforded the opportunity to rethink this architecture, and mitigate the issues we found with the previous app.
Traditionally, engineers develop feature code in Android apps with an Activity or a Service as an anchor. Our novel solution, built on the open source RIBs cross-platform mobile architecture we previously developed for our new rider app, centered around the idea that Activities and Services did not have to be a structural foundations of the app. Instead, RIBs let us build an architecture for the app where Activities and Services are not part of the core components, simplifying the business logic and streamlining the code.
While our approach is not directly applicable to most classes of mobile apps, it should serve as a new perspective on how to write apps, such as navigation services and geo-based games, that often run for many hours.
Our technical issues using Services
When writing our previous driver app, we followed a common pattern among Android developers: create an Activity and run logic related to its UX as part of it, while creating Services to run non-UX related logic. Depending on whether the driver-partner is online or offline, we also need to start a foreground Service to keep the app alive even when there is no Activity in the foreground.
Many features need to be available when the app is in either the background or the foreground. For example, while a driver is waiting for their next trip, they might put the app in the background, but we still need to display the dispatch offer when it arrives.
Our old application structure, shown in simplified form in Figure 2, below, made use of multiple Services to build background features:
Let’s identify a few of the symptoms caused by the structure or our previous driver app:
- Feature classes that might otherwise only need to exist in a small part of our application exist in the application scope. As a result, we end up with two different components for offer screens, one for the foreground and one for the background. Any shared classes used by both can’t assume that the user is logged-in or that the user is online, requiring engineers to add additional checks throughout their code and manually clean up the in-memory state when it becomes stale. (For more on this topic, see our previous article, Rewriting Uber Engineering’s Android Rider App with Deep Scope Hierarchies, which discusses the problems caused by poor scoping.)
- Feature code sometimes becomes duplicated. Given the need to write a foreground and background variants for the same feature, such as VOIP support, it seems reasonable to independently implement code for variants of each feature. Repeating this pattern across dozens of features increases the complexity of the codebase.
- Heads-up notifications pop up every time the app is backgrounded. Since some features get duplicated in an Activity and inside the Services, we couldn’t run these Services and the Activity at the same time. This meant that we needed to turn off some foreground Services every time we foregrounded the app, then restart them when we backgrounded the app. Restarting these Services retriggers the appearance of heads-up notifications, an unnecessary distraction for driver-partners.
- Logic that decides how long the app should run in the background is distributed across multiple foreground Services. Spreading the logic in such a manner made it difficult to reason about the conditions that keep the application alive and draining the battery.
In addition to all the above, working with Services can be difficult. For example, on a tiny percentage of Android OS variants, the Service lifecycle methods are invoked in the wrong order. On another tiny percentage of devices, foreground Services don’t keep the app alive consistently without bizarre workarounds.
We wanted to structurally mitigate these issues in the new version of our driver app.
The core of our approach to re-architecting the Android driver app boils down to two principles:
- Services don’t need to be a structural component in our application. We can take advantage of the benefits of sticky foreground Services without any features in our codebase acknowledging the existence of Services.
- Activities are optional. Many of the high-level state transitions, transient states, and screens in our application behave similarly regardless of whether the application currently has an Activity or not. So why not build one version of these feature classes and allow them to choose how to vary their behavior in response to being foregrounded or backgrounded?
The new driver app was developed using RIBs (Router Interactor Builder). In summary, RIB architecture allows us to create modular, business logic-focused components. Each RIB is an independent component comprised of an Interactor (business logic), Router (navigation), and Builder (dependency) that can be added as a child to another RIB. (Read Architecting Uber’s New Driver App in RIBs for an overview of how we used RIBs to rewrite our driver app.)
We wrote the core hierarchy of our application independently of the existence of an Activity. For example, in Figure 3, below, the Online RIB is attached when the driver is online regardless of whether the application has an Activity. Similarly, the Navigation RIB runs whenever a user opts into using Uber’s navigation, regardless of whether there is an Activity to visually display navigation directions or not.
In other cases, some RIBs are written once and re-instantiated when switching between Activity and non-Activity cases. For example, when the driver app receives a rider offer, the Online RIB attaches the Offer RIB to a window if no Activity exists, and attaches the Offer Screen to the OnlineView if an Activity does exist. The Online RIB observes a stream of View objects emitted when an Activity is attached to the App Root RIB so it knows when to switch between the two variants. The Offer RIB itself operates independently, unaffected by whether it is attached to a window or to an OnlineView.
A single Service
All of the Service code in our app is written in a single 100-line file. This Service’s sole job is to keep the app alive in the background and restart the app when the OS temporarily kills it to free up memory. The rest of the application requests this behavior by incrementing or decrementing a KeepAliveCount. When the count is greater than zero the app is kept alive with a sticky foreground Service. When the count is decremented below zero the foreground Service is killed.
For example, in Figure 3, the KeepAliveCount is incremented when the application starts the Online RIB.
We can easily monitor this single Service to ensure it isn’t misused in production or doesn’t cause battery drain. Furthermore, there is only one location where we need to work around bugs in the Service framework.
Our solution doesn’t come without costs. In addition to the benefits our approach provides, it also creates some downsides:
- This structure is unconventional and requires strict conformance. Some pre-existing features, developed for other apps, require their own Services. We need to refactor this code to be Service agnostic.
- In many cases, engineers are forced to reason about how their features should function both in the background and in the foreground, regardless of whether the feature ever appears in the background.
- We reduce stale state issues by reducing the number of global states. However, this approach introduces a new type of stale data issue where some RIBs outlive their Views, potentially creating memory leaks from Views inside these RIBs. Although not a common practice in conventional Android architecture, our approach of making Activities, and by extension Views, optional means we need to emit Views reactively to pass them to RIBs.
Services and Activities don’t need to be an architectural component of your app. The Android community contains a lot of ideas and philosophies for structuring apps that work for many cases. Our paradigm fits our architectural principle of focusing on the business logic and keeping View as a peripheral component in the app’s RIBs structure. This paradigm works well for apps like ours that frequently need to be switched between the background and foreground and be kept running for many hours.
Index of articles in Uber driver app series
- Why We Decided to Rewrite Uber’s Driver App
- Architecting Uber’s New Driver App in RIBs
- How Uber’s New Driver App Overcomes Network Lag
- Scaling Cash Payments in Uber Eats
- How to Ship an App Rewrite Without Risking Your Entire Business
- Building a Scalable and Reliable Map Interface for Drivers
- Engineering Uber Beacon: Matching Riders and Drivers in 24-bit RGB Colors
- Architecting a Safe, Scalable, and Server-Driven Platform for Driver Preferences
- Building Real-time Earnings Into Our New Driver App
- Activity/Service as a Dependency: Rethinking Android Architecture in Uber’s New Driver App
Yohan Hartanto, a software engineer at Uber, currently works to scale the development of the driver app to be reliable and highly performant. In the past, his experience includes building Android SDKs for Fabric, and developing an Android music app for Amazon.
Brian Attwell worked as a staff software engineer at Uber.
Posted by Yohan Hartanto, Brian Attwell
From Light to Dark: The Story Behind Dark Mode on the Android Uber App
October 5 / Global
How the Uber Membership Team Developed the ActionCard Design Pattern to Do More with Less
February 2 / Global
Auto insurance maintained by Uber
Selective Column Reduction for DataLake Storage Cost Efficiency
Introducing flat rates, a new way to earn in Orlando
Washington State Driver Information