Architecting a Safe, Scalable, and Server-Driven Platform for Driver Preferences with RIBs
March 1, 2019 / GlobalThis article is the eighth 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.
Driver-partners in each of the 600-plus cities where Uber operates face unique urban characteristics. For instance, San Francisco, home to our corporate headquarters, features a large bay ringed by cities, with a limited number of bridges connecting them. Paris’ vast urban area daily sees a huge number of tourists in its center. Likewise, cities as diverse as Mumbai, Athens, and Rio De Janeiro all have unique characteristics that can make navigating them challenging. Given the diversity of these urban landscapes, we want to let driver-partners across our global markets customize their experiences on our platform as much as possible, allowing them to seamlessly fit Uber into their lives.
We began this effort by adding a few simple preferences to the driver app, such as the ability to filter dispatches, either rides or food delivery requests, from our platform to only UberX or Uber Eats, or whether or not to accept cash trips in markets where that option is available. Furthering this work, we added a feature to let driver-partners specify areas they would like to accept rides in based on their schedule and geographic convenience (for instance, a driver-partner might want to be back in their neighborhood by 6 p.m. to pick up their child from soccer practice, or need to be downtown by 8:30 a.m. for work).
To best understand the needs of users across the globe, we rely on groups such as our City Operations and Safety teams, which have intimate knowledge of local conditions. Leveraging their insights, we built the Preferences Hub of our new driver app to make it easy for these teams to add driver app preferences that are meaningful to users in their cities. By giving driver-partners more preferences to choose from, these teams can create a safer and more convenient experience for driver-partners in their cities.
To enable rapid development and safe experimentation of the Preferences Hub, we built the platform as a set of reusable RIBs components with the interface and business logic fully driven by our back-end systems. The plugin structure of Uber’s open source mobile architecture allows local modifications that do not threaten the core functionality, preventing conflicts and dependency issues. As more opportunities arise for different feature teams, RIBs’ flexible, easy-to-adopt plugin design reduces mobile adoption costs by allowing teams to simply leverage backend services and use existing components to deliver new preferences.
Interface widgets
Providing Uber’s teams with a standard library of interface widgets in the Preferences Hub lets them respond quickly to requests from City Operations teams about new preference requirements. The goal in creating these widgets was making them simple to implement and universal enough to apply to a wide range of use cases.
Figures 2 and 3, above, showcase two of the reusable interface components: a toggle widget (e.g., enable/disable cash trips), and a multi-select widget (e.g., selecting preferred drop-off areas). In this way, the mobile app takes a back-end-driven approach to displaying each preference and keeps tabs on which preferences are selected until the driver hits save. At that point, the app sends the preferences to the backend, adjusting the dispatch preferences for this particular user.
Out-of-the-box, generic widgets can work for the vast majority of preferences, but a crucial point behind the architecture of the Preferences Hub is that it is scalable and customizable. We cannot predict future requirements, so we also added support for building custom components that can look very different from the widgets we currently currently provide. Figure 5, below, shows a custom multi-select interface component to improve the usability of selecting trip type preferences
Building a safe and scalable platform
Opening up this area of our app to remote teams to more intimately understand local conditions is great, but now we have another problem to consider: where should they put their code? Imagine the simplest scenario where we have a single class responsible for all the business and routing logic, controlled by the backend, as shown below:
if preference.id == “trip_type_pref” { // custom widget rendering code for trip type pref // custom widget business logic for trip type pref } else if preference.id == “dropoff_areas_pref” { // custom widget rendering code for dropoff areas pref // custom widget business logic for dropoff areas pref } else { // render generic multiselect widget // generic widget business logic } |
This approach is not scalable, as each time a new component is added the class gets larger. Continual changes also carry the risk of propagating mistakes. When your platform is used by millions of people around the world, a simple mistake can have severe consequences
In order to avoid if/else nightmares and bloated files but provide a safe development workflow for others to plug into our preferences platform, we leverage Uber’s open source RIBs architecture and its plugin framework.
Code isolation with RIBs
We designed RIBs to encourage code isolation, letting each team, feature, or component have its own workspace. For example, the custom-built trip type preference component, which lets drivers choose whether or not to accept passengers or food deliveries or both, shown above doesn’t need to interact with any other components. It is isolated in its own world, lessening the worry of conflicts or potential bugs leaking into other components.
The code sample below shows how we implement a component in RIBs. As you can see, it is isolated from similar components:
if preference.id == “trip_type_pref” { let tripTypePrefRouter = tripTypePrefBuilder.build() router.attachChildPrefRouter(tripTypePrefRouter) } else if preference.id == “dropoff_areas_pref” { let dropoffAreasPrefRouter = dropoffAreasPrefBuilder.build() router.attachChildPrefRouter(dropoffAreasPrefRouter) } else { let genericMultiSelectRouter = genericMultiSelectBuilder.build() router.attachChildPrefRouter(genericMultiSelectRouter) } |
Prior to RIBs, each new component in our mobile architecture processed all its rendering logic and configuring some business logic in the same class. With RIBs, we’ve enforced isolation of rendering and business logic entirely to each component’s own RIB, making for cleaner code. However, we still have the problem of developers making changes to the core code in the Preferences Hub whenever they need to add a new component, and the if/else nightmare remains. To minimize changes to the code inside the Preferences Hub platform, we leverage plugins.
Safety with plugins
The Preferences Hub uses plugins to make runtime decisions about what components to use. It solves the problem described above by forcing developers to create plugins for their components for integration with our PreferencesPluginPoint, shown in Figure 6, below:
Zooming out and looking at the app’s RIBs architecture from a higher level, shown in Figure 7, below, we can see that the Preferences Hub itself is a plugin of another plugin point inside another RIB. In other words, PreferencesPluginPoint is one of the many nested plugins within the Uber app, helping us reduce code complexity while keeping the app scalable.
Remember the if/else statements above? Using RIBs plugin architecture, our code now looks like this:
let plugin = preferencesPluginPoint.createPlugin(for: preference) let router = plugin.build() router.attachChildPrefRouter(router) |
Although this code seems very simple, we still need to determine which component to build for a specific preference when we use the createPlugin function. We achieve this by enforcing each plugin to conform to a shared protocol that exposes an isApplicable function. Let’s use the DropoffAreaPreferences class as an example:
class DropoffAreasPrefPlugin: PluginInterface { init(dependency: PreferencesComponentDependency) { super.init(pluginSwitch: .dropoffAreasPref) { context in return DropoffAreasPreferenceComponentBuilder(dependency: dependency) } } override func isApplicable(for preference: Preference) { return preference.id == “DROPOFF_AREAS” && preference.type == .multiselect } } |
The preference response from the backend will give us an id and a type, so in DropoffAreaPrefPlugin we define the isApplicable function to be valid for those specific descriptors.
Let’s backtrack now and look at what we accomplished using RIBs to implement Preference Hub:
- We enforced code isolation so that components can be built without knowledge of the existence of others, drastically reducing the chance of a bug in one leaking to another.
- We eliminated the need to make any changes to the Preferences Hub core code. To add a new component, we simply add it as an array item to the PluginPoint, offering compile time safety.
While this solved two parts of the challenge, we still had one problem to address. How do we maintain the entire app’s ongoing reliability and performance when introducing a new component? The solution: another key part of the RIBs framework, plugin switches. This component, shown as an enum value for .dropoffAreasPref in the above code example, are required to initialize the plugin. The enum value specified by the developer will map to a string value, and must be explicitly enabled in our experiments platform to work.
In the sample code, below, we enable a plugin using our experiments platform, which lets us deploy a feature to a subset of users and gain analytics about its efficacy:
“experiments”: [ “plugin_switch_dropoff_areas_preferences”: true ] |
At runtime and as part of the createPlugin call, we will check against the experiments platform before even checking the isApplicable function to determine whether a specific plugin is enabled and should be created. This lets us disable specific plugins on the fly if we observe that the app is crashing. When the Preferences Hub calls createPlugin for that preference, we no longer return the crashing component, falling back to the next eligible and applicable component instead, as shown in Figure 8, below.
Putting all this together, we now have a Preferences Hub that is backend driven, customizable, safe, and ready to support driver preferences from other teams.
The philosophy and architecture design of the Preferences Hub has since influenced several hub-style features in the Uber driver app. For example, the Promotions Hub, where our driver-partners can view all sorts of upcoming promotions, combines the benefit of not requiring changes to core app code when introducing a new promotion type and the flexibility of implementing customized interface components and logic if needed.
At a higher level, our approach to building the Preferences Hub serves as an example for how product teams across the industry can think about architecting their platforms for optimal flexibility and ease-of-use. Product requirements, like the needs of our driver-partners, are constantly evolving. Future product requirements are often uncertain, so teams must develop with agility, openness, and experimentality in mind.
Moving forward
Like other features on our driver app, there are always improvements to be made and we’re continuing to listen to our driver-partners in order to improve the Preferences Hub.
For instance, in the future, we plan to improve the interface components for each preference, adding support for hyperlinks and rich text to provide more context. For example, if a driver is unable to select a specific preference, we want the ability to include pop-up text to tell them why, and potentially provide a link for them to sign up for a new product or feature.
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 a Real-time Earnings Tracker into Uber’s New Driver App
- Activity/Service as a Dependency: Rethinking Android Architecture in Uber’s New Driver App
Interested in developing mobile applications used by millions of people every day? Consider joining our team as an Android or iOS engineer!
Banner photo by JESHOOTS.COM on Unsplash.
Brett Dupree
Brett Dupree is a Software Engineer on Uber’s Driver Experience team.
Quynh Nguyen
Quynh Nguyen was a Senior Software Engineer on Uber’s Driver Experience team.
Bao Lei
Bao Lei is a Senior Software Engineer on Uber’s Driver Experience team. He occasionally drives for Uber to experience the Uber driver app firsthand.
Posted by Brett Dupree, Quynh Nguyen, Bao Lei
Related articles
Most popular
Introducing the Prompt Engineering Toolkit
Serving Millions of Apache Pinot™ Queries with Neutrino
Your guide to NJ TRANSIT’s Access Link Riders’ Choice Pilot 2.0
Connecting communities: how Harrisburg University expands transportation access with Uber
Products
Company