Uber’s hypergrowth forces our developers to engineer stability into our apps using resourceful techniques.
In 2016, for instance, we created and open sourced Runtime Annotation Validation Engine (RAVE), a data model validation framework that uses Java annotation processing to tackle the number one cause of crashes in our Android apps: NullPointerExceptions (NPEs). NPEs are a common issue with languages like Java that do not have nullability built into their type system. By leveraging the annotations you already use, RAVE acts as a shield that protects against crashes or hard-to-spot bugs caused by invalid data.
To commemorate today’s release of RAVE 2, we explore how we eliminated the vast majority of NPEs in our apps using this powerful tool.
NullPointerExceptions in Uber’s Models
In Android apps, NPEs are frequently thrown when null data is accessed in models. Static analysis tools like Infer help catch NPEs at compile time but are not capable of determining whether data received at runtime (i.e., from network or storage) conforms to the set of expectations that are described by the annotations present in models.
One of the largest contributors to NPEs in our Android apps came from making assumptions regarding the data we used in our models. Consider the Rider model object shown below:
This model uses nullness annotations to inform consumers whether or not return types can be returned null. While these annotations are capable of warning developers working in an integrated development environment (IDE) when null types are unchecked or incorrectly used, they do not provide any safety at runtime. For example, when an app receives data and uses it to inflate model objects, there is no enforcement requiring that the data conforms to the annotations present in the model.
This scenario frequently occurred when deserializing objects from the network while using Gson. Since Gson does not check that the model objects it creates respect nullness annotations, an NPE can occur when you try to access annotated @NonNull data and an API returns null for something that is supposed to be @NonNull. Even when APIs behave according to their specs, sometimes their corner cases are poorly documented, unknown, or change overtime, which can cause NPEs. (For example, if you are expecting an API to return an empty array when it has no data to return, but instead it returns null.)
RAVE to the Rescue
To solve this problem, Uber created RAVE. When it comes to preventing NPEs and bugs that result from consuming invalid data, RAVE has a variety of use cases. Some applications include:
- Validating network responses to ensure they match what the client expects
- Avoiding errors caused by stale schemas when fetching data from disk
- Verifying that models are still valid after mutation
- Ensuring third party APIs do not crash your app when they provide unexpected data
RAVE validates model objects at runtime, accomplishing this using annotation processing to generate validation code based on the Android support annotations in your models. The validation code is then executed when data is received at runtime.
To better illustrate how RAVE works, let us define an app’s boundary as the border where data is received (from network data requests, device disk storage, etc.) RAVE ensures the data that enters your app adheres to the set of expectations described by your model’s annotations. It can do this regardless of where the data comes from, demonstrated below:
RAVE works with the annotations—nullness, value constraint, and typedef—that you already use in your Android apps. It also provides two annotations we built ourselves to support custom validation: @MustBeTrue and @MustBeFalse.
To use RAVE, you must opt your model into validation using the @Validated annotation, shown below:
The@Validated annotation takes a class literal to a RAVE ValidatorFactory, a concrete class that implements RAVE’s ValidatorFactory interface. You need to create a ValidatorFactory for each module that has classes you want to validate with RAVE, shown below:
The generated validator is run when you prompt RAVE to validate your model by calling the Rave.validate() API. You should always access RAVE by using the Rave.getInstance() API.
When Rave.validate() is called, RAVE uses RaveValidatorFactory_Generated_Validator to ensure MyModel.getSomeString()does not return null and MyModel.customValidationLogic()returns true. In the event that neither of these conditions are met, RAVE throws the checked exception, RaveException. This exception returns an error message and details that help pinpoint bugs.
RAVE in Android Apps
After we integrated RAVE into the two largest entry points of data in our apps (disk and network), NPEs went from being the primary reason our Android apps crashed to disappearing from our top ten list of crashers by volume. Let us take a look at two specific RAVE use cases to highlight how the framework integrates with Android apps:
Prior to reading or writing data to disk, RAVE ensures that objects conform to their annotations, thereby preventing NPE crashes from occurring when model schemas change between app versions. We do this by using the KeyValueStore API, whose implementation validates objects after they are read and before they are written.
When KeyValueStore.putObject() is called, our implementation of KeyValueStore calls Rave.validate(object). If the object is not RAVE-enabled or does not conform to its annotations, RaveException is thrown for developers to handle. This is done to ensure that invalid models are not persisted.
We also call Rave.validate() before returning data to consumers when KeyValueStore.getObject() is called. This ensures that if a model definition has been updated in a more recent version of the client, data read from disk still conforms to the annotations the model contains after the update. If it does not conform to these annotations, RAVE throws a RaveException for consumers to handle.
We use Retrofit 2 as the interface to make network calls in our Android apps. To use RAVE with Retrofit 2, we wrote a custom converter factory, RaveConverterFactory, that validates network responses returned by converter that is capable of deserializing JSON.
This converter validates deserialized models when Rave.validate() is called. In the event that the model does not pass validation, a RaveException is thrown to the consumer that invoked the network call. The sample app included in the RAVE repository demonstrates the use of this converter and validates network responses from GitHub’s API using Retrofit 2 and RAVE.
Interested in contributing to this project? Share your own custom validation annotations for RAVE on GitHub and help us increase the stability of Android.
Behrooz Khorashadi, Eric Leung, and Warren Smith are software engineers on Uber’s Mobile Development team.