Building the New Uber Freight App as Lists of Modular, Reusable Components
August 22, 2019 / GlobalAs Uber Freight marked its second anniversary, we went back to the drawing board to redesign its app. The original carrier app was successful for owner-operators with one or two drivers, but it wasn’t optimized for larger fleets—feedback we were hearing directly from customers. It let carriers find and move freight from point A to point B, but did not support multi-stop loads with multiple pick-ups and drop-offs.
On the engineering side, our team was growing in size. Each piece of technical debt became another unnecessarily complex example in our codebase. Our engineering velocity decreased—simple features took weeks to deploy. Efficiency was not scaling with increased headcount.
We saw the app redesign effort as a great opportunity to rethink how our app was built. Internally, we wanted to ensure that the sum of our team was greater than the individual parts. Externally, we wanted to quickly give carriers new features and improved user experiences without compromising quality.
Component-driven development
In 2016, Uber’s Mobile Platform team rewrote our rider app on a new (now open source) mobile architecture called RIBs. In 2017, the Driver team rewrote our driver app and adopted that same architecture.
In an effort to future-proof the app, the original Uber Freight app was written using the RIBs architecture from the very start. For Uber Freight’s launch in May 2018, the app was built by combining multiple RIBs, structures consisting of routers, interactors, and builders that make up the architecture of Uber’s open source, cross-platform, mobile architecture. It had good code isolation, a well thought out RIB tree with deep scopes, and decent separation of concerns.
However, many RIBs in the app’s structure grew too big and complex. It became difficult to share and reuse similar pieces of the UI and logic without refactoring major parts of the app.
The issue was that the design of the Uber Freight app relied heavily on lists. New features were integrated into existing feeds. List RIBs grew in size and complexity, and became difficult to maintain. Different lists often shared smaller pieces of UI and experiences, but the similar parts were buried within large classes, which discouraged code reuse.
By itself, the RIBs architecture wasn’t granular enough to facilitate UI and logic reuse. Code duplication became a problem, which made development frustrating and achieving consistency difficult.
In an effort to increase efficiencies and improve developer velocity, we decided to build a component-driven framework to complement the existing RIBs architecture. UI and logic could be broken down into even smaller pieces within RIBs, and our entire app could be composed of these individual, self-contained components.
Building the new framework
We built our new framework leveraging four major steps, each addressing a specific need:
- Since the app is list heavy, we built a ListView UI framework using RecyclerViews and UICollectionViews to compose arbitrary UI components. The Uber Freight app primarily lists loads for carriers and their drivers. Users can scroll through multiple feeds displaying new, current, and past loads, load details, entry points to post their trucks and wait for matched opportunities, information about freight facilities, recommendations, and many other products and features.
- Since the app shares a consistent design pattern throughout multiple features and screens, we built modular, reusable ListView components. While RecyclerViews and UICollectionViews are generally used to display dynamic lists of content, we chose to build both dynamic and static screens by composing components together. This way, each component can be easily reused in other features without needing to duplicate the UI, presentation logic, or business logic.
- Back-end APIs to reduce mobile complexity. While the original architecture of the Uber Freight app already took a server-driven approach, it required a lot of mobile logic to massage raw back-end data. We built our new APIs and data models from the ground up to enable more direct mappings between lists of back-end data and lists of mobile UI components, allowing the client code to focus more on business and presentation logic than data transformation.
- Since new features are likely to be built into existing list feeds, we used plugins to cleanly separate logic when mapping backend data into ListView components. Instead of building user experiences hidden deep within menus and submenus, new features and product offerings are accessible directly within the app’s main feeds. In order to prevent our feed RIBs from ballooning in size and complexity, we adopted a factory pattern using plugins to map lists of backend data into lists of mobile components. This pattern prevents us from writing glue code in the feed RIBs, ensuring a clean separation between the list components and the list itself.
The ListView UI framework
To facilitate building views by composing shared UI components, we built a lightweight ListView framework. Dynamic and static screens are built with RecyclerViews (Android) and UICollectionViews (iOS), as depicted in Figure 2 below. This framework was inspired by Airbnb’s open source Epoxy library for Android, which lets engineers build screens in a simple, declarative manner.
Building a generic list adapter
Setting up RecyclerViews and UICollectionViews usually requires substantial configuration boilerplate. A data source implementation is required to manage what shows up in the list, and different types of UI within the list need to be registered to the data source.
In general, most apps implement RecyclerView.Adapter or UICollectionViewDataSource to display lists of one or two different views. In the original Uber Freight app, we had double digit implementations of adapters and data sources, each supporting the necessary UI of a particular screen or feature. The duplicated logic grew and became difficult to maintain. implementation differences confused new engineers. Design and product inconsistencies crept into the app — UI would differ slightly in appearance across screens (e.g. font size, padding, and color shades), and components would behave differently across features (e.g. some buttons were missing click feedback).
Using dependency inversion, we created a generic adapter that can display any UI component. Each ListView component implements a common interface — it knows its UI layout, and how to bind that layout to its data. The list adapter simply keeps a registry of ListView components, and defers to the component to display itself on-screen.
Because every screen in our new app resuses the same adapter implementation that can display any component, sharing UI and logic is trivial. Any new UI can be built by simply composing together new and existing ListView components.
Separating business logic from presentation logic
Because of how RecyclerViews and UICollectionViews work, we had to be conscious of two different types of logic when building ListView components:
- Presentation logic, the logic to present what is on-screen. As the user scrolls, parts of a list may be off-screen and invisible to the user. RecyclerViews and UICollectionViews will recycle views from off-screen data and reuse them to display on-screen data. Therefore, a ListView component may need to re-present its data in a recycled view.
- Business logic, any other background logic. For example, some components may execute a network call to update its data, so that what is displayed on screen is up to date.
Each type of logic needs its own lifecycle. Presentation logic is executed every time a component needs to show up on-screen. Business logic, however, requires a separate lifecycle because it can execute even while the component is off-screen.
Most ListView components in the Uber Freight app only require presentation logic. Those components present relatively simple views. We built the ListView framework to also support complex components that require a business lifecycle. On Android, they are represented as a viewless RIB. On iOS, they are built as a manager class tied to a RIB’s lifecycle.
As our feed screens grow in size and complexity, all of the business logic—click handling, background network calls, routing, and launching entirely new flows—can be handled by the components themselves. This prevents the parent list RIB from having to handle all the logic of its child components.
We built the ListView framework to seamlessly complement Uber’s existing RIBs architecture. We chose to use RIB lifecycles as the business lifecycle in the ListView framework, which resulted in a win-win. RIBs became a crucial piece in the framework, and developers already familiar with RIBs could now use them as ListView components.
Server-driven rendering
The Uber Freight app has always had server-driven UI rendering, with back-end APIs that send lists of data to display in a feed. The backend controls all of the ordering, sorting, and content in the app.
We wanted to maintain this server-driven approach and use our new component-driven UI framework to complement it. One problem we encountered in the original Uber Freight app was that as more features were added, mobile complexity grew in two ways. First, our data models would become quite large and had deeply nested structures, requiring cumbersome mobile logic to transform raw data into view-models. Second, each new feature required additional transformation logic. As a result, our list RIBs grew in size and complexity, which made them difficult to maintain and build upon.
Creating new data models using unions
At Uber, we use Apache Thrift to define all data models and auto-generate per-language (iOS, Android, Go, etc.) implementations. In the original Uber Freight app, we use the Thrift type struct + enum to model most of our mobile facing entities. For the new app design, we decided to use the Thrift union type extensively because we found it to be a more concise and accurate way to represent a list of items with different types, which is the core data structure that backs many of our feed-based pages. Union also has other neat features, such as mapping to Swift enum with associated type on iOS for ease of use.
Union is a struct abstraction that carries exactly one type of field, out of many possibilities. This abstraction allows the mobile code to be easily consumed as a struct, without needing to know the specific type (similar to a client interface or protocol). Consider, for example, a union called MyShape .
Every MyShape that the client receives is either a Rectangle, a Triangle, or a Circle. Auto-generated client data models can access the exact type and field, and can still treat everything as a MyShape model.
We carefully created union data models for each list feed in the app. The search feed endpoint, for example, returns a list of SearchCard union data. The search list RIBs do not need to know what specific type of card a SearchCard is, it can simply consume an array of SearchCards, and convert each SearchCard into its corresponding mobile list components.
If a particular design component, say a Rectangle, is used in multiple list feeds, we can include the Rectangle struct in different unions. The mobile code can then reuse the same logic needed to unpack and display a Rectangle if necessary. By carefully designing our back end to correspond to the mobile UI, we reduce the amount of transformation logic in client code.
Putting it all together
With the ListView UI framework, we had a component-driven front-end architecture. With Unions, we had thoughtfully designed APIs returning lists of back-end union models.
Plugins: Turning unions into mobile components
To transform a list of back-end unions into a list of mobile UI components, we began using Plugins from Uber’s RIB architecture as component factories.
Consider a back-end union like the example, below:
Here, we hold a MyUnionPluginPoint with a list plugin factories registered (PluginFactoryA, PluginFactoryB, and PluginFactoryC). Each plugin factory is only applicable for a certain case in this union. For example, PluginFactoryA handles A types, PluginFactoryB handles B types, and PluginFactoryC handles C types.
As shown in Figure 4 above, each back-end data model from List { A, B, C } is sent as input into the plugin factory. The output for each input is a list of mobile components–a one-to-many mapping between back-end union and list view components. For example, a single back-end model can return a title component, a message component, and a button component.
These ListView components are combined and then displayed in the RecyclerView or UICollectionView.
To see how this abstraction keeps our codebase clean, consider the following example. Without plugins, transformation code becomes complex, difficult to read, and likely to introduce errors as shown, below:
The logic to transform A, B, and C are isolated in PluginFactory classes for A, B, and C respectively, eliminating the need to debug large if/else and switch statements. As the Uber Freight Engineering team grows, code isolation becomes more and more important. When new engineers are tasked with building feature D, not having to navigate through glue code affecting A, B, and C improves their developer experience.
With new back-end models, a new front-end framework, and new plugins as factories to translate from one to the other, the Uber Freight app is now a well-oiled, server-driven machine! For another example of how RIBs, plugins, and component-driven UI helps Uber scale—including experimentation, safety, and more server-driven goodness—please read Architecting a Safe, Scalable, and Server-Driven Platform for Driver Preferences with RIBs.
New foundations
Finally, we used the app redesign as a clean slate for the future. In addition to creating a component-driven framework, we took the opportunity to rebuild on more solid foundations:
- We extensively added and backfilled unit tests (and snapshot tests on iOS too).
- We created a UI gallery of ListView components that acts as a sample application. Since these components are the building blocks of our app, we can easily debug production issues in the gallery itself.
- We created a huge library of mocked back-end data, which further helps us debug components within the UI gallery. Since our app is server-driven, it used to be difficult to debug specific UI issues if the back end wasn’t sending the component. Now, we can simulate any component without needing to trigger the right conditions.
- We carefully and painstakingly logged an armada of analytics, enabling our data science teams to quantitatively research and debug how we can improve the user experience.
We are excited to share the new Uber Freight app with carriers and their drivers, and hope that our learnings throughout the process prove helpful to other mobile engineers.
Interested in building software to help improve the lives of carriers and make shipping more efficient? Consider joining our team!
Larry Wei
Larry Wei is a software engineer on the Uber Freight team working on Android.
Tong Pan
Tong Pan is a senior software engineer on the Uber Freight team working on iOS.
Senyang Zhuang
Senyang Zhuang is a software engineer on the Uber Freight team working on iOS.
Posted by Larry Wei, Tong Pan, Senyang Zhuang
Related articles
Most popular
Serving Millions of Apache Pinot™ Queries with Neutrino
How to Measure Design System at Scale
Your guide to NJ TRANSIT’s Access Link Riders’ Choice Pilot 2.0
Preon: Presto Query Analysis for Intelligent and Efficient Analytics
Products
Company