Taming WebSocket with Scarlet
How we developed Scarlet, a declarative Kotlin library, to make WebSocket integration easier on Android.
In mobile apps, the data layer is the source of truth for what to display on the screen. Maintaining it, however, became a headache when we integrated WebSocket APIs at Tinder earlier this year. To make WebSocket integration easier on Android, we developed Scarlet, a declarative Kotlin library.
WebSocket with Scarlet
Scarlet is easy to set up and maintain: declaring a WebSocket API client is as simple as declaring methods on an interface. When you pass your interface to Scarlet, it will generate an implementation. For example, GDAX WebSocket Feed API, which offers real-time cryptocurrency prices via WebSocket, takes 9 lines of code to integrate with Scarlet, while other WebSocket libraries require hundreds of lines of code.
Scarlet interprets methods in
GdaxService using reflection.
@Sendannotated method takes one parameter. When invoked, it sends an outgoing message to the server.
@Receiveannotated method, on the other hand, returns a stream of incoming messages or events about the current connection state. You need to subscribe to the stream to observe values.
According to the GDAX API documentation, to begin receiving real-time price
Tickerfrom GDAX, the client must first send a
Subscribe message to the server indicating which channels and products it wants to receive.
Messages can be declared as data classes. In this example, we are interested in the Bitcoin price in US dollars. So we want to subscribe to the
Subscribe message upon connection open and the server will start streaming tickers which contain the latest price.
Besides having a declarative API, Scarlet is also modular, customizable, and easily extensible because it follows a plugin architecture. Its behaviors are encapsulated by useful abstractions that can be easily swapped out like plugins. In addition to choosing from a variety of built-in plugins, you are empowered to provide your own plugins when building a Scarlet instance.
The GDAX example requires three built-in plugins:
- A WebSocket implementation based on OkHttp.
MoshiMessageAdapter.Factory, which uses Moshi to serialize data. Scarlet also supports Gson and protobuf. If you don’t specify any
MessageAdapter.Factory, you may use
RxJava2StreamAdapter.Factoryis used to support RxJava2. Scarlet also supports RxJava1. If you don’t provide any
StreamAdapter.Factory, you may use the built-in
Integration with Android
When the connection is open, WebSocket is straightforward. However, it demands additional efforts on mobile because of its stateful nature. A WebSocket connection may be closed for many reasons:
- Unstable network
- Server closure to release resources
- App enters background to conserve battery
When the connection is closed, the client needs to decide when to retry. While other WebSocket libraries hold the developer liable to keep the connection open, Scarlet manages retries and makes the connection state transparent to the developer. This is achieved with the help of two abstractions:
Lifecycle tells Scarlet when to connect to the server and to keep retrying if the server disconnects. As a stream of
Stopped), it encapsulates the scope of a WebSocket connection.
Lifecycle is particularly useful on Android because it is reusable and composable; e.g.:
- To keep the connection open only when the app is in the foreground, you can write a
Lifecyclefor the app foreground scope. (Scarlet comes with an
AndroidLifecycleplugin so that you don’t have to write one.)
- To keep the connection open only when the user is logged in, you can create a
Lifecyclefor the logged in scope.
- To satisfy both conditions, you can simply combine their
Lifecycles to create an app foreground and user logged in scope.
When the current
Started, Scarlet uses a
BackoffStrategy to determine how often it should retry after each connection failure. Scarlet comes with three backoff strategies:
ExponentialWithJitterBackoffStrategy. For more information on choosing backoff strategies, please see Exponential Backoff And Jitter.
With the desired
BackoffStrategy specified, Scarlet handles connection failures gracefully.
Writing Tests for Scarlet
Scarlet instance is created,
Lifecycle changes are fed into an internal state machine that manages network calls. To make state transitions declarative and readable, we invented a Kotlin DSL to write the Scarlet state machine in. For more information, please see StateMachine on Github.
The state machine, together with immutable states and events, results in very testable code. In addition, Scarlet comes with declarative testing utilities that make assertions about state transitions readable in unit tests.
What really makes the integration tests readable, however, is the decoupling from OkHttpClient. An integration test asserts the interactions between a Scarlet WebSocket client and a mock WebSocket server. In the beginning, Scarlet was coupled with OkHttpClient and only worked on the client side. Our declarative testing utilities made client states easily assertable. But to assert server states, we had to use a different set of APIs around MockWebServer. The mix of two different assertion styles made integration tests confusing.
To unify the testing APIs used in integration tests, we inverted the dependency on OkHttpClient so that Scarlet also worked on the server side. We made OkHttpClient and MockWebServer plugins of Scarlet. This change not only improved the readability of our integration tests, but also allowed Scarlet to work with any WebSocket libraries in the future.
Thanks for reading! Please try Scarlet out and tell us what you think. Scarlet is one of the tools we made in-house. It is currently used by the Tinder Android app in production, serving millions of users and handling billions of WebSocket messages every day. If being a part of this is interesting to you, please feel free to reach out to me @zhxnlai, or you can view our job openings here at Tinder — we’re always looking for talented engineers to join our team!