Removing 30% of our iOS crashes
In this article, we’ll walk through how we refactored the Option Chain (i.e. a list of available options contracts for a given security, learn more here) on iOS, which is the first thing customers see when trading options. You can learn more about Options Trading on our Learn platform, but this is not required as we will focus more on the technical implementation.
Why does this matter? First, our customers depend on this screen every day to manage their trades and we want them to have a world-class experience with great performance, UX and reliability. At the time we rolled out this project (as of 12/31/21) Options Trading accounted for roughly 45% of Robinhood Markets’ total revenue and approximately 65% of our options customers were on iOS, which meant that approximately 30% of our revenue, at the time, flowed through this screen.
The Option Chain was first written in late 2017 when we first introduced Options Trading. Since then a lot has evolved in our codebase. We adopted a new architectural design pattern we call NAPA, which stands for “Not A Perfect Architecture.” You can think about it as a lightweight VIPER architecture. We also introduced Titan, our data and networking framework based on the CQRS design pattern. Titan manages everything from getting data from the network to persisting it locally in cache and database. Titan also provides the capability to observe data changes with RxSwift.
Why migrate now?
First, stability. 30% of ALL iOS crashes were coming from this screen (and most of our customers don’t even see this screen since they don’t trade options). We wanted to get rid of those crashes to provide a better and safer experience for our customers. This code is written in a five year-old
UIViewController which is based on
NSFetchedResultsController. Due to the data and view logic intertwined in the implementation, and the indeterministic updates from different threads with the legacy infrastructure, maintaining view data consistency has been quite challenging.
Next, performance. Since the original implementation was a tight coupling of Core Data models, with hard to predict faulting behavior, we ran into performance issues that are difficult to reproduce and mitigate. For instance, switching between expirations dates or between call and put options could be slow in some cases but very hard to reproduce.
Last but not least, developer experience. We had massive UIViewControllers, a lot of tech debt, and adding new features and new experiments in this codebase was very complex. By migrating it to our modern architecture, NAPA and Titan, we were able to have 30% fewer lines of codes and a more modular architecture that helps us scale and is easier to test.
The Options Chain screen
This screen has a feature-rich UI with dense, real-time information that varies based on the experience-level of the customer. Our app needs to keep individual pages always up-to-date as information like price and volume keep changing (options quotes, stock price). Also, depending on the Options Level of the customer (level 2 and level 3) we display different data and the interactions are not the same.
Better data management with Titan
Titan is our data access layer. It allows querying data through the network, locally in the database or the memory cache. Titan also allows us to mutate data.
For each Model stored in Titan we defined how it is fetched from the backend, saved locally in the database and the cache. We can define other parameters such as how to query a model by parameters, how often a model should be refreshed, how long the cache should live etc…
All this configuration is done by conforming to specific protocols. In the example below we conform to:
Cacheableto indicate the object can be save in the memory cache
LocalPersistableto be able to save instances of this model on disk (in the local database)
HTTPRemoteListQueryableto indicate the path of the endpoint to fetch a list of OptionQuote
PollObservableto specify the the polling interval of
This is just a few examples of protocols our models can conform to, the list is quite long and covers almost all of our use cases.
The Options Chain has a lot of network calls depending on the expiration date selected, the type and side of the contract selected, but also if the customer already owns some options contracts.
PaginatedOptionChainPresenter is the root presenter and owns a list of
OptionChainPresenter for each expiration date. We use an
UIPageViewController under the hood to be able to swipe between expiration dates.
OptionInstrumentPresenter is responsible for the data contained in a cell. You might think doing real time networking calls in a cell is a bit complex and should be done somewhere else. But it’s actually quite simple to do with Titan. In the example below, we retrieve the
breakevenPrice from the
OptionQuote model that is streamed and refreshed every few seconds. The
OptionInstrument model is injected into the presenter at the init.
Since the Option Chain is a crucial part of our product, and a lot of customers depend on it every day, we doubled down on testing to make sure we didn’t introduce any regressions. Our testing approach consisted of abstracting our components behind protocols, heavily relying on Snapshot tests for UI components with a lot of variations and validating the whole trading flow with End to End tests.
Protocol Oriented Approach
Similar to VIPER we also have Router, Presenter and View in NAPA.
- Routers are components responsible for handling the navigation between different Views. It instantiates the Presenter and the View of the same module.
- Presenters are responsible for the logic displayed in a View, how the data is retrieved, modified and passed into the View.
- Views are the UI of our module, it can be based on
UIViewControllerbut could also inherit from
UIViewfor a smaller component.
In the example below, the
OptionChainPresenter doesn’t own a direct reference to the
OptionChainRouter, instead we use the
OptionChainRoutable protocol. This allows us to decouple our router from our presenter. We can easily plug another router when needed, it’s easier to test and mock those components.
We have 28 possible variations of the Option Instrument Row. This component is responsible for displaying an option instrument and details about it like the price variation, the breakeven, the chance of profit etc… The Option Quote data like the price, breakeven or the daily variation are displayed in real time. When the customer double-taps on the cell the contract can be added or removed from the customer’s watchlist, in those case we display a loading indicator and a ✅ icon. We don’t display the same data if it’s a put or a call (breakeven percentage or chance of profit). The UI is rendered differently when the “+” is tapped (multi-select for level 3 options customers) and we also show on this cell when the customer owns this specific contract. Lastly, we need to make sure it displays correctly in both dark and light modes, which adds to its complexity.
With all the complexity and modularity of the Option Instrument Row we needed a tool that makes sure a regression is not introduced while adding a new feature or refactoring the code. Snapshot testing is a great tool as it takes a screenshot of a UI component with given inputs and compares this screenshot to a previously recorded image. If the screenshots don’t match, something changed in the code and the test will break.
We were able to cover all the 28 possible cases, but also adding new behaviors without introducing any regression. Now we’re more confident to add new features in this complex row without breaking existing features.
End to End tests
At Robinhood we heavily rely on End to End tests for our critical paths, including Options Trading. It guarantees that for every commit merged into our main branch, those critical flows are working. Since we already had several End to End tests on Options, it was simple to modify those E2E tests.
This following video is a recording of an End to End test of when a customer buys a call option.
For instance we already had a test called
OptionsOpenLongCallTest. This test buys a call option contract. For this test we just have to create a subclass and inject the variation of the feature flag:
As you can see, adding a new variation of an existing E2E was straightforward. We just have to inject the experiments we want to override and the Robinhood app will use those experiments to run the test. It’s also quite important for us to test this Option Chain Page works exactly as the previous one and all the same features are available. This really helped us have a lot of confidence in this migration.
Note: End to End tests are quite costly and can take a lot of time to pass on the CI. Writing only one test per class helps to parallelize them amongst several simulators on the CI (using Xcode Parallel Testing).
As explained earlier, there is a lot of logic and complexity in this cell. Data comes from different endpoints, some are refreshed in real time, some come from the local database. We also need to stop refreshing data when the cell disappears and so on…
We use RxSwift extensively, and all the data visible in this cell is retrieved through Observers:
This works well and we’re able to see the option instruments in real time but… It’s a bit slow while scrolling through the list of cells and is not smooth. Adding and removing several observers while scrolling is quite costly in terms of performance compared to a delegation pattern. It’s one of the small disadvantages of using an Observer Pattern, it’s slower to observe an object changes (it’s done asynchronously and might not be executed on the same runloop) rather than calling directly a callback through delegation.
About RxSwift and MainScheduler
As you may have noticed we don’t use observe(on:
MainScheduler.instance), but instead we observe it on
MainScheduler will always dispatch asynchronously on the main thread. This ensures the current thread is not blocked (with 10 observers per cell). But it was still a bit slow and wasn’t working well on our Snapshot tests.
UIScheduler was built internally; it dispatches on the main thread only if we’re on a background thread, otherwise the code will be executed immediately. So instead of dispatching 10 times asynchronously, we decided to dispatch on the next runloop only one time per cell, and do all our subscriptions at this time:
Caveat: To avoid concurrency issues we need to capture the disposeBag before the asynchronous dispatch and pass it to observers.
We have our own experiment tool that allows us to do some A/B testing and measure the impact of a feature (or refactor in this case). We also created a Perceived Wait Time framework that allows us to measure how much elapsed for a specific action. For instance, the time elapsed from when a customer taps the trade options button until the moment all options are visible with their latest price.
What were the results of this migration? First, 30% of our iOS crashes were gone, this was a huge improvement by itself.
Next, we were able to improve the Perceived Wait Time when switching from Call to Put (or vice versa), it’s 200% faster than before. Switching between expiration dates is also 37% faster.
Last, we reduced the number of lines of code by 30% on this complex screen. By using our latest frameworks, NAPA and Titan, it’s easier to navigate and iterate on the codebase. This is critical in the long term because as we continue to build new features for Option traders we are able to build them faster and safer. Being consistent in our framework usage across our app also helps us while onboarding new engineers.
Options trading entails significant risk and is not appropriate for all customers. Customers must read and understand the Characteristics and Risks of Standardized Options before engaging in any options trading strategies. Options transactions are often complex and may involve the potential of losing the entire investment in a relatively short period of time. Certain complex options strategies carry additional risk, including the potential for losses that may exceed the original investment amount.
Options trading is offered through Robinhood Financial LLC, a registered broker-dealer, with clearing services offered through Robinhood Securities, LLC, a registered broker-dealer. Both are wholly-owned subsidiaries of Robinhood Markets, Inc.
All trademarks, logos and brands are the property of their respective owners.
Robinhood Markets Inc. and Medium are separate and unique companies and are not responsible for one another’s views or services.
© 2022 Robinhood Markets, Inc.