This article is the sixth 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.
When Uber decided on a full rewrite of our driver app, we used it as an opportunity to step back and take a look at how we could improve some of the app’s core technologies. One technology we focused on was the map display, which had become difficult to work with and was implicated in many bugs that drivers had encountered.
The map display of our previous driver app suffered from the typical problems of legacy software, designed with one purpose in mind and acquiring new features over time. Those features occasionally conflicted in their control over the map display, resulting in a subpar experience for users.
Carbon, our driver app rewrite, gave us an opportunity to re-architect the map, and we came up with a framework that is both easier to work with and more reliable than the previous implementation. Setting the map as a background layer and building three distinct components to regulate the display proved an elegant solution that can scale for growth and let us seamlessly implement new features, enabling us to better serve our driver-partners.
Legacy conflicts
When we first launched our previous driver app in 2013, its architecture was much simpler. For instance, our map’s UI only displayed the driver’s location along with the pickup or dropoff location. Given this limited feature set, it made sense to have a single class manage the map; in our case, it was called MapViewController.
Over time, we added many new map-related features to the app, such as in-app navigation, a gas station finder, rider location sharing, and driver destination filtering. Each of these features added additional code to MapViewController, introducing complexity which made refining it or adding even more features difficult.
In 2015, we attempted to simplify the code by splitting its logic into two new view controllers: OffTripMapViewController and OnTripMapViewController. This division helped somewhat, but it also introduced new issues. For example, to avoid having the app recreate the map every time a driver went on or off-trip, we passed the map view back and forth between the two view controllers. Unfortunately, sometimes the timing was off, and we would send the map to the wrong view controller. Whenever this happened, the map would totally disappear, as shown in Figure 2, below, causing confusion for drivers:
Another issue occurred when some features needed to exist both on and off-trip. For example, in-app navigation has long allowed drivers to navigate while on-trip, but we also wanted drivers to be able to navigate to preferred destinations while off-trip. We ended up duplicating a large amount of navigation code so that it would work in both view controllers. This problem illustrated a fundamental issue with the architecture: access to the map was segmented by app state, but features that needed to use the map had lifespans that didn’t fit neatly into these segments.
The new map architecture
For the new driver app, we came up with a brand new map architecture to prevent these types of bugs from appearing. We made the map a background view that persists throughout the lifetime of the app so that we no longer need to move it from one controller to another.
Three components provide access to the map: the Layer Manager, the Padding Provider, and the Camera Director. These objects are made available to any part of the code, so map features can be written in a decentralized manner. With the new app, there is no longer a need to put map logic in dedicated view controllers, and each map-related feature can have its own lifespan.
The Layer Manager: Building sandboxes for map elements
Driver app features often want to draw elements on the map, such as markers or overlays. In the previous version of the driver app, feature code drew elements by calling functions on the map view itself. This approach worked fine as long as the code was perfectly well-behaved, but in practice that wasn’t always the case. Sometimes features inadvertently forgot to remove their map markers, leaving stray elements on the map that persisted until the app was relaunched, an example of which can be seen in Figure 5. Other times, features removed others’ elements because they wanted a blank canvas for the map; the other feature had no idea that its elements had been removed, and behaved as if the driver could still see these elements on the map.
Implemented in our new driver app, the Layer Manager solves these issues by providing a sandbox for each map feature. Instead of adding elements directly to the map view, features create their own map layers and register them with the Layer Manager. The map layer’s interface allows engineers to add and remove map elements, but does not provide access to any other layers. Features can no longer interfere with map elements that they do not own.
In some cases though, a feature really does require that the the map be cleared of all other elements. For example, when a driver accepts a new ride request, we want the dispatching screen to only show map elements relevant to the proposed pick-up to keep the UI clean and easy-to-follow. In these cases, features can ask the Layer Manager to register an exclusive map layer. This temporarily hides all other map layers; once the exclusive layer is unregistered, the other layers can return.
The Layer Manager also has a built-in safeguard to protect against features forgetting to clean up their elements. In Uber’s RIBs architecture, business logic typically lives in Interactor classes. When registering a map layer, it is mandatory to provide an interactor with which to bind the lifespan of the layer so that when an interactor is deactivated the map layer is automatically removed along with all of its elements. Stray map elements are much less likely to persist since they are unable to live longer than the feature that created them.
The basic interface of the Layer Manager keeps map layers segregated so they cannot conflict with each other, as depicted below:
class MapLayerManager {
func add(layer: MapLayer, interactorScope: InteractorScope, exclusive: Bool)
func remove(layer: MapLayer)
}
class MapLayer {
func add(marker: MapMarker)
func remove(marker: MapMarker)
func add(polygon: MapPolygon)
func remove(polygon: MapPolygon)
func add(polyline: MapPolyline)
func remove(polyline: MapPolyline)
func add(tileOverlay: MapTileOverlay)
func remove(tileOverlay: MapTileOverlay)
}
The Padding Provider: Handling app chrome on top of the map
Having the map as a background view means that app chrome, in other words, elements such as panels and buttons, will often cover it. The map needs to account for the chrome in our app so that it can show the driver the streets and locations they need. It would be unfortunate if, for example, the pick-up pin or the driver’s current location were obscured by an opaque panel.
Since there is no built-in way for the app to know that the map is obscured, we created the Padding Provider as a component that tracks the visible portion of the map. Any chrome that partially covers the map registers themselves with the Padding Provider as a padding source. These chrome elements let the provider know how much they extend (relative to the edges of the screen) into the map’s bounds. The Padding Provider aggregates all of its sources and produces an observable stream of the overall edge insets. Any code that wants to show a certain map region to the user uses this stream to ensure the desired region will be visible.
As with with map layers, each map padding source needs to be bound to the lifespan of the feature that created it. However, instead of binding to an Interactor (which contains business logic), padding sources are bound to the view controller so when the view disappears its map padding will automatically disappear too.
The Padding Provider allows padding sources to be registered, controlling which portions of the map can be covered and facilitating an improved navigation experience for drivers. Its basic interface is depicted below:
class MapPaddingProvider {
var edgeInsetsStream: Observable<EdgeInsets> { get }
func add(paddingSource: MapPaddingSource,
viewControllerScope: ViewControllerScope)
func remove(paddingSource: MapPaddingSource)
}
class MapPaddingSource {
var edgeInsets: EdgeInsets { get set }
}
The Camera Director: encouraging cooperation between features
The map camera is a term that describes the point in 3D space, a vantage point, that is above and looks down at the map. It is analogous to a film camera, where the map is the scene being filmed and the user is looking through the camera lens. Features often change the map camera to show a specific region to the user, such as when a driver is dispatched and we want to display the pick-up location.
In the previous version of the driver app, any feature could alter the camera’s vantage point by directly changing its properties on the map view. Sometimes features would want to continuously update the camera, such as during in-app navigation where it follows the user’s location as they drive. The problem with this approach is that features were generally unaware of any other features that may be controlling the camera at the same time. In this case, if two features tried to control the map camera at the same time, the map would jump back and forth between two regions, a disorienting experience for the driver.
The new driver app solves these issues with the Camera Director. Instead of letting features freely control the map camera, the Camera Director provides a way for them to influence the camera by registering a camera rule. A camera rule has an interface for providing latitude/longitude coordinates that should be included on screen. Most features do not need to display a specific region, but may be programmed to ensure that their markers or other elements are being show to the user. The Camera Director aggregates these rules to come up with an overall map camera that includes all of the desired coordinates.
This rules-based approach allows many features to function in cooperative manner, a better outcome than the previous approach by which the camera’s vantage point would jump from location to location and no guarantees could be made around what the user would see.
However, sometimes the rules-based approach is insufficient, since it does not allow for any camera control beyond expanding the visible map region. For example, when tapping on a map marker that shows an area of high rider demand, we want to display an inspection sheet showing a route to that area, as depicted in Figure 7, below. In this situation, the map UI is placed in a temporary mode where it focuses on a particular marker, and we do not want it to include unrelated locations in the visible map region. For these types of cases, we can request exclusive camera control from the Camera Director. This provides an object that lets us set a specific map camera, but only one feature can have exclusive access at a time and features are notified when they lose camera control.
Like the Layer Manager (and similar to the Padding Provider), the Camera Director requires a RIBs Interactor to bind to whenever a feature registers a new rule or requests exclusive camera access. In this way, features can only affect the map camera while they are active.
The basic interface of the Camera Director lets features set their camera requirements, where the requirements are defined using rules and/or exclusive access, as depicted below:
class MapCameraDirector {
func add(cameraRule: MapCameraRule, interactorScope: InteractorScope)
func remove(cameraRule: MapCameraRule)
func requestCameraControl(interactorScope: InteractorScope) -> MapCameraHandle
func relinquishCameraControl(handle: MapCameraHandle)
}
class MapCameraRule {
var boundingLocations: [LocationCoordinate2D] { get set }
}
class MapCameraHandle {
var isActiveStream: Observable<Bool> { get }
func set(camera: MapCamera, duration: TimeInterval)
}
Moving forward
In the new driver app, we took away the ability for individual features to directly control the map, which can lead to conflicts and stray visual elements, as we observed in the past. With our new framework, features access the map more safely through the mediation of the Layer Manager, Padding Provider, and Camera Director. We also took advantage of the RIBs architecture, which requires engineers to write features in such a way, through the use of interactor and view controller binding, that they do not leave visual elements on the map when not in use.
Providing these guardrails has resulted in a more robust and scalable map architecture. Additionally, development of map features has been more effortless, with less need to add workarounds for other features. Ultimately, the most important benefit is to our driver-partners– with the new driver app, they will have a more reliable experience picking up and dropping off riders.
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
Chris Haugli
Chris Haugli is an iOS engineer on the Driver Navigation Experience team. He joined Uber in 2012 and has worked on a variety of projects since then, but has in recent years been focusing on maps and navigation in the driver app.
Posted by Chris Haugli
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